From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/19] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed205..eefaa2a799 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304d..112273a41d 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e87240810..5563268b8e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 5382865806..69a3d5ee3e 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635d..bac2b78fc1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 02/19] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f857232..d7c86be966 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e93..df67ca1690 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca750..a3eccc116d 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1ddc..b86e426c47 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From d14666e0818befcb60399da3567720682da91fc5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 1 Sep 2026 20:38:16 +0530 Subject: [PATCH 03/19] fix: pass nodes to lazy inspector panels --- .../ui/panels/parametric-inspector.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 903542cecf..b1bc2132ed 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -131,7 +131,7 @@ export function ParametricInspector({ return ( - + ) @@ -170,7 +170,7 @@ export function ParametricInspector({ ))} {TrailingSection && ( - + )} {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && ( @@ -270,14 +270,31 @@ function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined { // Cache lazy custom panel components by their loader so React.lazy isn't // re-invoked across renders. -const customPanelCache = new WeakMap<() => Promise, ComponentType>() +const customPanelCache = new WeakMap<() => Promise, ComponentType<{ node: AnyNode }>>() -function resolveCustomPanel(loader: () => Promise<{ default: ComponentType }>): ComponentType { +function resolveCustomPanel( + loader: () => Promise<{ default: ComponentType }>, +): ComponentType<{ node: AnyNode }> { const cached = customPanelCache.get(loader) if (cached) return cached const Comp = lazy(loader) - customPanelCache.set(loader, Comp as ComponentType) - return Comp as ComponentType + customPanelCache.set(loader, Comp as ComponentType<{ node: AnyNode }>) + return Comp as ComponentType<{ node: AnyNode }> +} + +// Subscribe to the full node only where the custom panel contract needs it. +// Keeping this below ParametricInspector preserves the inspector's narrow +// per-field subscriptions while ensuring lazy panels receive their live node. +function CustomPanelSlot({ + Component, + nodeId, +}: { + Component: ComponentType<{ node: AnyNode }> + nodeId: AnyNodeId +}) { + const node = useScene((s) => s.nodes[nodeId]) + if (!node) return null + return } // ─── Per-field renderers ───────────────────────────────────────────── From c8eb97a7ee6e088b6f7d974d0380cae32d59a116 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 7 Sep 2026 17:17:14 +0530 Subject: [PATCH 04/19] feat: add immersive WebXR editor support --- XR-CHECKLIST.md | 28 + apps/editor/.gitignore | 2 + apps/editor/app/page.tsx | 18 +- apps/editor/app/xr/page.tsx | 10 + apps/editor/app/xr/scene/[id]/page.tsx | 13 + apps/editor/components/build-tab.tsx | 248 +----- apps/editor/components/scene-loader.tsx | 125 +-- apps/editor/components/viewer-toolbar.tsx | 3 +- .../components/xr/wand-panel/build-panel.tsx | 251 ++++++ apps/editor/components/xr/wand-panel/index.ts | 1 + .../components/xr/wand-panel/paint-panel.tsx | 355 ++++++++ .../components/xr/wand-panel/panel-icon.tsx | 81 ++ .../components/xr/wand-panel/panel-layout.ts | 52 ++ .../xr/wand-panel/settings-panel.tsx | 754 +++++++++++++++++ .../xr/wand-panel/spatial-controls.tsx | 476 +++++++++++ .../components/xr/wand-panel/spatial-line.tsx | 53 ++ .../components/xr/wand-panel/spatial-text.tsx | 124 +++ .../xr/wand-panel/terrain-settings-panel.tsx | 146 ++++ apps/editor/components/xr/wand-panel/theme.ts | 9 + .../components/xr/wand-panel/wand-panel.tsx | 49 ++ .../xr/wand-panel/xr-wand-input-overlay.tsx | 36 + .../components/xr/xr-editor-input-bridge.tsx | 721 ++++++++++++++++ .../xr/xr-emulator-test-harness.tsx | 779 ++++++++++++++++++ .../components/xr/xr-preview-environment.tsx | 329 ++++++++ .../xr/xr-render-error-boundary.tsx | 48 ++ apps/editor/components/xr/xr-runtime.tsx | 79 ++ apps/editor/lib/bootstrap.ts | 3 + apps/editor/lib/build-palette.test.ts | 57 ++ apps/editor/lib/build-palette.ts | 253 ++++++ apps/editor/lib/build-tab-state.ts | 26 + apps/editor/lib/xr/editor-input.test.ts | 146 ++++ apps/editor/lib/xr/editor-input.ts | 126 +++ apps/editor/lib/xr/emulator-ray.test.ts | 23 + apps/editor/lib/xr/emulator-ray.ts | 29 + apps/editor/lib/xr/emulator.ts | 74 ++ apps/editor/lib/xr/preview-window.test.ts | 30 + apps/editor/lib/xr/preview-window.ts | 32 + .../editor/lib/xr/reference-space-ray.test.ts | 37 + apps/editor/lib/xr/reference-space-ray.ts | 22 + apps/editor/lib/xr/settings.test.ts | 106 +++ apps/editor/lib/xr/settings.ts | 182 ++++ .../editor/lib/xr/wand-panel-settings.test.ts | 74 ++ apps/editor/lib/xr/wand-panel-settings.ts | 58 ++ apps/editor/lib/xr/wand-panel.test.ts | 99 +++ apps/editor/next.config.ts | 18 +- apps/editor/package.json | 4 + apps/editor/tsconfig.json | 6 +- bun.lock | 74 +- package.json | 5 + .../editor/editor-layout-mobile.tsx | 45 +- .../components/editor/editor-layout-v2.tsx | 30 +- .../editor/src/components/editor/grid.tsx | 3 + .../components/editor/group-rotate-handle.tsx | 47 +- .../editor/handles/handle-arrow.tsx | 57 +- .../editor/handles/use-handle-drag.ts | 40 +- .../editor/src/components/editor/index.tsx | 57 +- .../editor/wall-move-side-handles.tsx | 38 +- .../ui/panels/parametric-inspector.tsx | 18 +- packages/editor/src/hooks/use-keyboard.ts | 20 +- packages/editor/src/index.tsx | 23 +- .../editor/src/lib/parametric-node-update.ts | 37 + packages/editor/src/lib/scene.test.ts | 99 +++ packages/editor/src/lib/scene.ts | 24 +- .../src/lib/spatial-pointer-input.test.ts | 50 ++ .../editor/src/lib/spatial-pointer-input.ts | 56 ++ packages/viewer/package.json | 1 + .../src/components/viewer/frame-limiter.tsx | 6 + .../viewer/src/components/viewer/index.tsx | 250 ++++-- .../src/components/viewer/post-processing.tsx | 4 + .../components/viewer/viewer-camera.test.ts | 57 ++ .../src/components/viewer/viewer-camera.tsx | 71 +- packages/viewer/src/index.ts | 16 +- .../src/lib/renderer-capability.test.tsx | 17 + .../viewer/src/lib/renderer-capability.ts | 4 +- .../src/xr/distance-aware-ray-pointer.tsx | 162 ++++ packages/viewer/src/xr/frame-loop.test.ts | 192 +++++ packages/viewer/src/xr/frame-loop.ts | 135 +++ .../god-mode/constants/god-mode-constants.ts | 4 + packages/viewer/src/xr/god-mode/index.ts | 3 + .../god-mode/input/god-mode-hand-controls.tsx | 95 +++ .../src/xr/god-mode/lib/palm-grab.test.ts | 36 + .../viewer/src/xr/god-mode/lib/palm-grab.ts | 74 ++ .../xr/god-mode/lib/scale-interaction.test.ts | 109 +++ .../src/xr/god-mode/lib/scale-interaction.ts | 155 ++++ .../store/god-mode-hand-store.test.ts | 20 + .../xr/god-mode/store/god-mode-hand-store.ts | 48 ++ .../store/god-mode-view-store.test.ts | 15 + .../xr/god-mode/store/god-mode-view-store.ts | 15 + .../src/xr/god-mode/ui/god-mode-controls.tsx | 172 ++++ .../constants/human-mode-constants.ts | 8 + packages/viewer/src/xr/human-mode/index.ts | 5 + .../input/controller-locomotion.tsx | 89 ++ .../xr/human-mode/input/hand-locomotion.tsx | 210 +++++ .../human-mode/input/human-collision-rig.tsx | 87 ++ .../human-mode/lib/capsule-collision.test.ts | 45 + .../xr/human-mode/lib/capsule-collision.ts | 119 +++ .../viewer/src/xr/human-mode/lib/comfort.ts | 7 + .../src/xr/human-mode/lib/hand-locomotion.ts | 79 ++ .../viewer/src/xr/human-mode/lib/hand-pose.ts | 38 + .../viewer/src/xr/human-mode/lib/haptics.ts | 10 + .../src/xr/human-mode/lib/human-input.test.ts | 61 ++ .../src/xr/human-mode/lib/locomotion.test.ts | 36 + .../src/xr/human-mode/lib/locomotion.ts | 43 + .../xr/human-mode/lib/origin-navigation.ts | 30 + .../viewer/src/xr/human-mode/lib/snap-turn.ts | 14 + .../xr/human-mode/store/collision-store.ts | 16 + .../store/hand-locomotion-joystick.ts | 60 ++ .../human-mode/store/locomotion-settings.ts | 16 + .../src/xr/human-mode/ui/comfort-vignette.tsx | 53 ++ .../xr/human-mode/ui/hand-locomotion-zone.tsx | 178 ++++ .../xr/human-mode/ui/human-mode-controls.tsx | 19 + packages/viewer/src/xr/input-visuals.tsx | 174 ++++ .../viewer/src/xr/mode-switching/index.ts | 7 + .../lib/scene-scale-transition.test.ts | 34 + .../lib/scene-scale-transition.ts | 72 ++ .../lib/thumb-mode-gesture.test.ts | 29 + .../mode-switching/lib/thumb-mode-gesture.ts | 42 + .../mode-switching/store/player-mode.test.ts | 15 + .../xr/mode-switching/store/player-mode.ts | 27 + .../mode-switching/ui/player-mode-scene.tsx | 300 +++++++ packages/viewer/src/xr/pointer-cursor.test.ts | 36 + packages/viewer/src/xr/pointer-cursor.ts | 24 + packages/viewer/src/xr/pointer-filter.test.ts | 57 ++ packages/viewer/src/xr/pointer-filter.ts | 19 + .../viewer/src/xr/pointer-ring-material.ts | 12 + .../src/xr/presentation-background.test.ts | 10 + .../viewer/src/xr/presentation-background.ts | 5 + .../viewer/src/xr/presentation-context.tsx | 23 + packages/viewer/src/xr/session-root.tsx | 168 ++++ packages/viewer/src/xr/store.test.ts | 22 + packages/viewer/src/xr/store.ts | 17 + packages/viewer/src/xr/support.ts | 11 + patches/iwer@2.3.0.patch | 26 + patches/three@0.185.1.patch | 39 + wiki/architecture/README.md | 1 + wiki/architecture/xr.md | 117 +++ 136 files changed, 10652 insertions(+), 437 deletions(-) create mode 100644 XR-CHECKLIST.md create mode 100644 apps/editor/app/xr/page.tsx create mode 100644 apps/editor/app/xr/scene/[id]/page.tsx create mode 100644 apps/editor/components/xr/wand-panel/build-panel.tsx create mode 100644 apps/editor/components/xr/wand-panel/index.ts create mode 100644 apps/editor/components/xr/wand-panel/paint-panel.tsx create mode 100644 apps/editor/components/xr/wand-panel/panel-icon.tsx create mode 100644 apps/editor/components/xr/wand-panel/panel-layout.ts create mode 100644 apps/editor/components/xr/wand-panel/settings-panel.tsx create mode 100644 apps/editor/components/xr/wand-panel/spatial-controls.tsx create mode 100644 apps/editor/components/xr/wand-panel/spatial-line.tsx create mode 100644 apps/editor/components/xr/wand-panel/spatial-text.tsx create mode 100644 apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx create mode 100644 apps/editor/components/xr/wand-panel/theme.ts create mode 100644 apps/editor/components/xr/wand-panel/wand-panel.tsx create mode 100644 apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx create mode 100644 apps/editor/components/xr/xr-editor-input-bridge.tsx create mode 100644 apps/editor/components/xr/xr-emulator-test-harness.tsx create mode 100644 apps/editor/components/xr/xr-preview-environment.tsx create mode 100644 apps/editor/components/xr/xr-render-error-boundary.tsx create mode 100644 apps/editor/components/xr/xr-runtime.tsx create mode 100644 apps/editor/lib/build-palette.test.ts create mode 100644 apps/editor/lib/build-palette.ts create mode 100644 apps/editor/lib/xr/editor-input.test.ts create mode 100644 apps/editor/lib/xr/editor-input.ts create mode 100644 apps/editor/lib/xr/emulator-ray.test.ts create mode 100644 apps/editor/lib/xr/emulator-ray.ts create mode 100644 apps/editor/lib/xr/emulator.ts create mode 100644 apps/editor/lib/xr/preview-window.test.ts create mode 100644 apps/editor/lib/xr/preview-window.ts create mode 100644 apps/editor/lib/xr/reference-space-ray.test.ts create mode 100644 apps/editor/lib/xr/reference-space-ray.ts create mode 100644 apps/editor/lib/xr/settings.test.ts create mode 100644 apps/editor/lib/xr/settings.ts create mode 100644 apps/editor/lib/xr/wand-panel-settings.test.ts create mode 100644 apps/editor/lib/xr/wand-panel-settings.ts create mode 100644 apps/editor/lib/xr/wand-panel.test.ts create mode 100644 packages/editor/src/lib/parametric-node-update.ts create mode 100644 packages/editor/src/lib/scene.test.ts create mode 100644 packages/editor/src/lib/spatial-pointer-input.test.ts create mode 100644 packages/editor/src/lib/spatial-pointer-input.ts create mode 100644 packages/viewer/src/components/viewer/viewer-camera.test.ts create mode 100644 packages/viewer/src/xr/distance-aware-ray-pointer.tsx create mode 100644 packages/viewer/src/xr/frame-loop.test.ts create mode 100644 packages/viewer/src/xr/frame-loop.ts create mode 100644 packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts create mode 100644 packages/viewer/src/xr/god-mode/index.ts create mode 100644 packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx create mode 100644 packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts create mode 100644 packages/viewer/src/xr/god-mode/lib/palm-grab.ts create mode 100644 packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts create mode 100644 packages/viewer/src/xr/god-mode/lib/scale-interaction.ts create mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts create mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts create mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts create mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts create mode 100644 packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx create mode 100644 packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts create mode 100644 packages/viewer/src/xr/human-mode/index.ts create mode 100644 packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx create mode 100644 packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx create mode 100644 packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx create mode 100644 packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/capsule-collision.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/comfort.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/hand-pose.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/haptics.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/human-input.test.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/locomotion.test.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/locomotion.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/origin-navigation.ts create mode 100644 packages/viewer/src/xr/human-mode/lib/snap-turn.ts create mode 100644 packages/viewer/src/xr/human-mode/store/collision-store.ts create mode 100644 packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts create mode 100644 packages/viewer/src/xr/human-mode/store/locomotion-settings.ts create mode 100644 packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx create mode 100644 packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx create mode 100644 packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx create mode 100644 packages/viewer/src/xr/input-visuals.tsx create mode 100644 packages/viewer/src/xr/mode-switching/index.ts create mode 100644 packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts create mode 100644 packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts create mode 100644 packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts create mode 100644 packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts create mode 100644 packages/viewer/src/xr/mode-switching/store/player-mode.test.ts create mode 100644 packages/viewer/src/xr/mode-switching/store/player-mode.ts create mode 100644 packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx create mode 100644 packages/viewer/src/xr/pointer-cursor.test.ts create mode 100644 packages/viewer/src/xr/pointer-cursor.ts create mode 100644 packages/viewer/src/xr/pointer-filter.test.ts create mode 100644 packages/viewer/src/xr/pointer-filter.ts create mode 100644 packages/viewer/src/xr/pointer-ring-material.ts create mode 100644 packages/viewer/src/xr/presentation-background.test.ts create mode 100644 packages/viewer/src/xr/presentation-background.ts create mode 100644 packages/viewer/src/xr/presentation-context.tsx create mode 100644 packages/viewer/src/xr/session-root.tsx create mode 100644 packages/viewer/src/xr/store.test.ts create mode 100644 packages/viewer/src/xr/store.ts create mode 100644 packages/viewer/src/xr/support.ts create mode 100644 patches/iwer@2.3.0.patch create mode 100644 patches/three@0.185.1.patch create mode 100644 wiki/architecture/xr.md diff --git a/XR-CHECKLIST.md b/XR-CHECKLIST.md new file mode 100644 index 0000000000..febd3a7d24 --- /dev/null +++ b/XR-CHECKLIST.md @@ -0,0 +1,28 @@ +# XR Implementation Checklist + +- [x] 1. Create an authoritative XR tool manifest +- [x] 2. Build a deterministic emulator harness +- [x] 3. Verify controller pointer capture and drag lifecycle +- [x] 4. Verify hand pinch capture and drag lifecycle +- [x] 5. Verify panel selection and nested pagination +- [x] 6. Verify Select-tool fallback and cancellation +- [x] 7. Verify scene selection and deselection +- [x] 8. Verify movement and resize handles +- [x] 9. Verify settings-panel generation and updates +- [x] 10. Verify wall creation and editing +- [x] 11. Verify door and window wall placement +- [x] 12. Verify fence creation and editing +- [x] 13. Verify slab creation and editing +- [x] 14. Verify ceiling creation and editing +- [x] 15. Verify column and block workflows +- [x] 16. Verify elevator and spawn workflows +- [x] 17. Verify shelf, kitchen, and stair workflows +- [x] 18. Verify roof creation and editing +- [x] 19. Verify roof-feature placement and editing +- [x] 20. Verify MEP tool workflows +- [x] 21. Verify Paint tool workflows +- [x] 22. Verify terrain sculpting workflows +- [x] 23. Verify undo, redo, and interaction cancellation +- [x] 24. Verify God and Human mode workflows +- [x] 25. Verify controller/hand parity and XR rendering stability +- [ ] 26. Run complete emulator and physical-headset regression testing diff --git a/apps/editor/.gitignore b/apps/editor/.gitignore index 684fd2301d..00e86211d1 100644 --- a/apps/editor/.gitignore +++ b/apps/editor/.gitignore @@ -35,3 +35,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env*.local + +certificates/ diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 9361f3696f..c38d63071b 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,6 +1,8 @@ 'use client' import { Editor, ItemsPanel } from '@pascal-app/editor' +import { createViewerXRStore } from '@pascal-app/viewer' +import { useWebXRFeature, WebXRToolbarButton } from '@pascal-local/plugin-webxr' import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' @@ -87,9 +89,14 @@ const SIDEBAR_TABS = [ const PROJECT_ID = 'local-editor' export default function Home() { + const webXR = useWebXRFeature(createViewerXRStore) + return ( -
- {PROJECT_ID === 'local-editor' && ( +
+ {PROJECT_ID === 'local-editor' && webXR.status !== 'active' && (
@@ -105,11 +112,16 @@ export default function Home() {
)} } - viewerToolbarRight={} + viewerToolbarRight={ + } /> + } + xr={webXR.xr} />
) diff --git a/apps/editor/app/xr/page.tsx b/apps/editor/app/xr/page.tsx new file mode 100644 index 0000000000..09485a0a0b --- /dev/null +++ b/apps/editor/app/xr/page.tsx @@ -0,0 +1,10 @@ +import { XRPreviewEnvironment } from '@/components/xr/xr-preview-environment' + +export default async function LocalXRPreviewPage({ + searchParams, +}: { + searchParams: Promise<{ source?: string }> +}) { + const { source } = await searchParams + return +} diff --git a/apps/editor/app/xr/scene/[id]/page.tsx b/apps/editor/app/xr/scene/[id]/page.tsx new file mode 100644 index 0000000000..872862222e --- /dev/null +++ b/apps/editor/app/xr/scene/[id]/page.tsx @@ -0,0 +1,13 @@ +import { XRPreviewEnvironment } from '@/components/xr/xr-preview-environment' + +export default async function SceneXRPreviewPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ source?: string }> +}) { + const { id } = await params + const { source } = await searchParams + return +} diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 9317ae12b7..d601125e18 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,16 +1,7 @@ 'use client' +import { RoofType as RoofTypeSchema, useRegistryVersion } from '@pascal-app/core' import { - nodeRegistry, - type RoofType, - RoofType as RoofTypeSchema, - useRegistryVersion, -} from '@pascal-app/core' -import { - CATALOG_ITEMS, - type FloorplanMode, - getFloorplanNodeExtension, - isFloorplanToolAvailableInMode, MaterialPaintPanel, TerrainSculptPanel, ToolOptionsPanel, @@ -19,7 +10,6 @@ import { useFloorplanMode, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' -import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' import { @@ -28,224 +18,27 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/toolbar-tooltip' +import { + activateBuildTool, + activateModularCabinetTool, + activatePaintMode, + activateRoofFeatureTool, + activateRoofType, + activateTerrainSculptMode, + BASE_BUILD_TYPES, + type BuildType, + collectBuildTypes, + collectRoofFeatures, + MEP_ITEMS, + MEP_TOOL_KINDS, + type MepItem, + MODULAR_CABINET_ICON, +} from '@/lib/build-palette' import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' import { cn } from '@/lib/utils' -/** - * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" - * group tile — its own sub-grid, like Roof's "Features". - */ -type MepToolKind = - | 'duct-segment' - | 'duct-fitting' - | 'duct-terminal' - | 'hvac-equipment' - | 'lineset' - | 'liquid-line' - | 'pipe-segment' - | 'pipe-fitting' - | 'pipe-trap' - -type BuildType = { - /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */ - id: string - label: string - /** Raster asset tile (legacy Build sidebar artwork). */ - iconSrc: string - /** Present for structure-tool types (absent for paint mode and the MEP group). */ - kind?: string - paletteOrder?: number - /** Non-placement special mode. */ - mode?: 'material-paint' | 'terrain-sculpt' -} - -type MepItem = { - /** Selection id — equals `kind`. */ - id: string - label: string - iconSrc: string - kind: MepToolKind -} - -// Same icons + ordering as the community Build sidebar, minus presets. -const BASE_BUILD_TYPES: BuildType[] = [ - { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, - { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, - { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, - { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, - { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, - { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, - { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, - { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, - { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, - { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, - { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, - { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, - { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, - // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). - { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, - { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, - { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, -] - const subscribeToClientMount = () => () => {} -function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { - const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) - const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ - ...type, - paletteOrder: - nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, - })) - for (const [kind, definition] of nodeRegistry.entries()) { - const presentation = definition.presentation - const extension = getFloorplanNodeExtension(definition) - if ( - baseKinds.has(kind) || - definition.presentation?.paletteGroup === 'roof-features' || - !extension?.tool || - !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || - !presentation || - presentation.hidden || - presentation.paletteSection !== 'structure' - ) { - continue - } - tools.push({ - id: kind, - kind, - label: presentation.label, - iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', - paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, - }) - } - tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) - return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] -} - -// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP -// tools had in the community Build sidebar. -const MEP_ITEMS: MepItem[] = [ - { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, - { - id: 'duct-terminal', - label: 'Register', - iconSrc: '/icons/registers.webp', - kind: 'duct-terminal', - }, - { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, - { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, - { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, - { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, -] - -const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') -const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' - -/** - * Activate a raw structure draw/cursor tool. Mirrors the editor's own - * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). - */ -function activateBuildTool(kind: string): void { - const ed = useEditor.getState() - const definition = nodeRegistry.get(kind) - const extension = getFloorplanNodeExtension(definition) - if ( - !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) - ) { - useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) - return - } - const preferredView = extension?.preferredView - if (preferredView) ed.setViewMode(preferredView) - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setCatalogCategory(null) - ed.setToolDefaults(kind, null) - ed.setMode('build') - ed.setTool(kind) -} - -function activateModularCabinetTool(): void { - const ed = useEditor.getState() - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setCatalogCategory(null) - ed.setMode('build') - ed.setTool('cabinet') -} - -/** Enter material-paint mode — the Build tab's "Painting" category. */ -function activatePaintMode(): void { - const ed = useEditor.getState() - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setMode('material-paint') -} - -/** - * Enter terrain-sculpt mode — the Build tab's "Terrain" category. No `setPhase`: - * `setMode` moves to the site phase itself, since sculpting is a site-phase mode. - */ -function activateTerrainSculptMode(): void { - useEditor.getState().setMode('terrain-sculpt') -} - -type RoofFeature = { - id: string - label: string - iconSrc: string - kind?: string -} - -const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' - -function collectRoofFeatures(): RoofFeature[] { - const features: RoofFeature[] = [] - for (const [kind, def] of nodeRegistry.entries()) { - if ( - def.capabilities.roofAccessory === undefined && - def.presentation?.paletteGroup !== 'roof-features' - ) { - continue - } - if (def.capabilities.wallOpeningPlacement) continue - const icon = def.presentation?.icon - features.push({ - id: kind, - kind, - label: def.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, - }) - } - return features -} - -/** - * Roof accessories and extensions surfaced under the Roof tile. Unlike the - * community editor these aren't DB presets — each is a registry kind, either - * carrying `capabilities.roofAccessory` or explicitly classified as a roof - * extension. They are enumerated at render time because the registry is - * populated during app bootstrap. Label + icon come from `presentation`; - * non-url icons fall back to the roof icon. - */ -function activateRoofFeatureTool(feature: RoofFeature): void { - const ed = useEditor.getState() - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setCatalogCategory(null) - ed.setMode('build') - if (feature.kind) ed.setTool(feature.kind) -} - -function activateRoofType(roofType: RoofType): void { - const editor = useEditor.getState() - if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') - editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType }) -} - /** * Build tab for the open-source standalone editor — a preset-less replica of * the community Build sidebar. Clicking a type activates its raw tool, drawn @@ -254,13 +47,6 @@ function activateRoofType(roofType: RoofType): void { */ // MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid) // is what the user is working in. -const MEP_TOOL_KINDS = new Set([ - ...MEP_ITEMS.map((item) => item.kind), - 'duct-fitting', - 'pipe-fitting', - 'pipe-trap', -]) - export function BuildTab() { const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index d7538ead71..3267d34a96 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -9,6 +9,8 @@ import { type SceneGraph, type SidebarTab, } from '@pascal-app/editor' +import { createViewerXRStore } from '@pascal-app/viewer' +import { useWebXRFeature, WebXRToolbarButton } from '@pascal-local/plugin-webxr' import { Hammer, Layers, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' @@ -108,7 +110,22 @@ function isLightPreviewQuery(searchParams: URLSearchParams): boolean { return disable.split(',').some((p) => p.trim() === 'postFx') } +function sceneUrl( + sceneId: string, + searchParams: URLSearchParams, + update: Record, +) { + const next = new URLSearchParams(searchParams) + for (const [key, value] of Object.entries(update)) { + if (value == null) next.delete(key) + else next.set(key, value) + } + const query = next.toString() + return `/scene/${sceneId}${query ? `?${query}` : ''}` +} + export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { + const webXR = useWebXRFeature(createViewerXRStore) const router = useRouter() const searchParams = useSearchParams() const versionRef = useRef(meta.version) @@ -241,60 +258,73 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { ) return ( -
- {conflict && ( -
-

Another session saved first — refresh?

-

- Your changes haven't been saved. Reload to pick up the latest version. -

-
+
+ {webXR.status !== 'active' && ( + <> + {conflict && ( +
+

Another session saved first — refresh?

+

+ Your changes haven't been saved. Reload to pick up the latest version. +

+
+ + +
+
+ )} + {saveError && !conflict && ( +
+

{saveError}

+
+ )} +
- + All scenes +
-
- )} - {saveError && !conflict && ( -
-

{saveError}

-
+ )} -
- - - All scenes - -
} - viewerToolbarRight={} + viewerToolbarRight={ + } /> + } + xr={webXR.xr} />
) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index a707288ecf..f9b3f7b7cd 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -695,7 +695,7 @@ export function CommunityViewerToolbarLeft() { ) } -export function CommunityViewerToolbarRight() { +export function CommunityViewerToolbarRight({ pluginActions }: { pluginActions?: ReactNode }) { return (
@@ -704,6 +704,7 @@ export function CommunityViewerToolbarRight() {
+ {pluginActions}
) diff --git a/apps/editor/components/xr/wand-panel/build-panel.tsx b/apps/editor/components/xr/wand-panel/build-panel.tsx new file mode 100644 index 0000000000..4177eb22fa --- /dev/null +++ b/apps/editor/components/xr/wand-panel/build-panel.tsx @@ -0,0 +1,251 @@ +'use client' + +import { type RoofType, RoofType as RoofTypeSchema, useRegistryVersion } from '@pascal-app/core' +import { useEditor, useFloorplanMode } from '@pascal-app/editor' +import { useMemo } from 'react' +import { + activateBuildTool, + activateModularCabinetTool, + activatePaintMode, + activateRoofFeatureTool, + activateRoofType, + activateSelectMode, + activateTerrainSculptMode, + collectBuildTypes, + collectRoofFeatures, + type RoofFeature, + XR_MEP_ITEMS, +} from '@/lib/build-palette' +import { ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' +import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' +import { PanelIcon } from './panel-icon' +import { getPageWithPinnedFirst } from './panel-layout' +import { PanelHeader, SpatialButton } from './spatial-controls' +import { SpatialText } from './spatial-text' +import { XR_WAND_THEME } from './theme' + +const ITEMS_PER_PAGE = 9 + +type PaletteEntry = { + active: boolean + iconSrc: string + id: string + label: string + select: () => void +} + +function tilePosition(index: number): [number, number, number] { + return [-0.255 + (index % 3) * 0.255, 0.225 - Math.floor(index / 3) * 0.215, 0] +} + +function PaletteTile({ entry, index }: { entry: PaletteEntry; index: number }) { + return ( + + + 12 ? 0.018 : 0.021} + maxWidth={0.19} + position={[0, -0.067, 0.012]} + textAlign="center" + > + {entry.label} + + + ) +} + +export function XRBuildPanel() { + const section = useXRWandPanelSettings((state) => state.buildSection) + const page = useXRWandPanelSettings((state) => state.buildPage) + const setBuildNavigation = useXRWandPanelSettings((state) => state.setBuildNavigation) + const mode = useEditor((state) => state.mode) + const activeTool = useEditor((state) => state.tool) + const roofDefaults = useEditor((state) => state.toolDefaults.roof) + const floorplanMode = useFloorplanMode((state) => state.mode) + const registryVersion = useRegistryVersion() + const buildTypes = useMemo(() => { + void registryVersion + return collectBuildTypes(floorplanMode) + }, [floorplanMode, registryVersion]) + const roofFeatures = useMemo(() => { + void registryVersion + return collectRoofFeatures() + }, [registryVersion]) + const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) + const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable' + + const entries = useMemo(() => { + const selectEntry: PaletteEntry = { + active: mode === 'select', + iconSrc: '/icons/select.webp', + id: 'select', + label: 'Select', + select: activateSelectMode, + } + + if (section === 'mep') { + return [ + selectEntry, + ...XR_MEP_ITEMS.map((item) => ({ + active: mode === 'build' && activeTool === item.kind, + iconSrc: item.iconSrc, + id: item.id, + label: item.label, + select: () => activateBuildTool(item.kind), + })), + ] + } + + if (section === 'roof') { + const roofTypes: PaletteEntry[] = ROOF_TYPE_OPTIONS.map((option) => ({ + active: mode === 'build' && activeTool === 'roof' && activeRoofType === option.value, + iconSrc: '/icons/roof.webp', + id: `roof-${option.value}`, + label: option.label, + select: () => activateRoofType(option.value as RoofType), + })) + return [ + selectEntry, + ...roofTypes, + ...roofFeatures.map((feature: RoofFeature) => ({ + active: mode === 'build' && activeTool === feature.kind, + iconSrc: feature.iconSrc, + id: feature.id, + label: feature.label, + select: () => activateRoofFeatureTool(feature), + })), + ] + } + + return [ + selectEntry, + ...buildTypes.map((type) => { + const isMepTool = + !!activeTool && + (activeTool.includes('duct') || + activeTool.includes('pipe') || + activeTool === 'lineset' || + activeTool === 'liquid-line' || + activeTool === 'hvac-equipment') + const active = type.mode + ? mode === type.mode + : type.id === 'kitchen' + ? mode === 'build' && activeTool === 'cabinet' + : type.id === 'mep' + ? mode === 'build' && isMepTool + : mode === 'build' && activeTool === type.kind + return { + active, + iconSrc: type.iconSrc, + id: type.id, + label: type.label, + select: () => { + if (type.id === 'mep') { + activateBuildTool('duct-segment') + setBuildNavigation('mep', 0) + } else if (type.id === 'roof') { + activateBuildTool('roof') + setBuildNavigation('roof', 0) + } else if (type.id === 'kitchen') { + activateModularCabinetTool() + } else if (type.mode === 'material-paint') { + activatePaintMode() + } else if (type.mode === 'terrain-sculpt') { + activateTerrainSculptMode() + } else if (type.kind) { + activateBuildTool(type.kind) + } + }, + } + }), + ] + }, [activeRoofType, activeTool, buildTypes, mode, roofFeatures, section, setBuildNavigation]) + + const current = getPageWithPinnedFirst(entries, page, ITEMS_PER_PAGE) + const title = section === 'main' ? 'Build' : section === 'mep' ? 'MEP' : 'Roof' + + return ( + + + {section !== 'main' && ( + { + setBuildNavigation('main', 0) + }} + position={[-0.3, 0.35, 0]} + size={[0.12, 0.055]} + > + + Back + + + )} + {current.items.map((entry, index) => ( + + ))} + {current.pageCount > 1 && ( + + setBuildNavigation(section, current.currentPage - 1)} + position={[-0.07, 0, 0]} + size={[0.055, 0.055]} + > + + ‹ + + + + {current.currentPage + 1}/{current.pageCount} + + = current.pageCount - 1} + name={`xr-build-${section}-next-page`} + onClick={() => setBuildNavigation(section, current.currentPage + 1)} + position={[0.07, 0, 0]} + size={[0.055, 0.055]} + > + + › + + + + )} + + ) +} diff --git a/apps/editor/components/xr/wand-panel/index.ts b/apps/editor/components/xr/wand-panel/index.ts new file mode 100644 index 0000000000..25c7f85fc3 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/index.ts @@ -0,0 +1 @@ +export { XRWandInputOverlay } from './xr-wand-input-overlay' diff --git a/apps/editor/components/xr/wand-panel/paint-panel.tsx b/apps/editor/components/xr/wand-panel/paint-panel.tsx new file mode 100644 index 0000000000..42f3043565 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/paint-panel.tsx @@ -0,0 +1,355 @@ +'use client' + +import { + getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, + getMaterialsForCategory, + MATERIAL_CATEGORIES, + type MaterialCatalogItem, + type MaterialCategory, + subscribeLibraryMaterials, + toLibraryMaterialRef, +} from '@pascal-app/core' +import { + cyclePaintScope, + getActivePaintMaterialLabel, + hasActivePaintMaterial, + type PaintHoverInfo, + paintScopeLabel, + useEditor, +} from '@pascal-app/editor' +import { useMemo, useRef, useSyncExternalStore } from 'react' +import { activatePaintMode } from '@/lib/build-palette' +import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' +import { PanelIcon } from './panel-icon' +import { getPage } from './panel-layout' +import { PanelHeader, SpatialButton } from './spatial-controls' +import { SpatialLine } from './spatial-line' +import { SpatialText } from './spatial-text' +import { XR_WAND_THEME } from './theme' + +const MATERIALS_PER_PAGE = 6 +const MATERIAL_TILE_SIZE: [number, number] = [0.215, 0.205] +const MATERIAL_PREVIEW_SIZE = 0.116 +const MATERIAL_GRID_TOP = 0.135 +const MATERIAL_GRID_ROW_GAP = 0.25 +const MATERIAL_PREVIEW_FRAME = MATERIAL_PREVIEW_SIZE / 2 + +const MATERIAL_PREVIEW_FRAME_POINTS: [number, number, number][] = [ + [-MATERIAL_PREVIEW_FRAME, -MATERIAL_PREVIEW_FRAME + 0.022, 0.014], + [MATERIAL_PREVIEW_FRAME, -MATERIAL_PREVIEW_FRAME + 0.022, 0.014], + [MATERIAL_PREVIEW_FRAME, MATERIAL_PREVIEW_FRAME + 0.022, 0.014], + [-MATERIAL_PREVIEW_FRAME, MATERIAL_PREVIEW_FRAME + 0.022, 0.014], + [-MATERIAL_PREVIEW_FRAME, -MATERIAL_PREVIEW_FRAME + 0.022, 0.014], +] + +function labelCategory(category: string) { + return `${category.charAt(0).toUpperCase()}${category.slice(1)}` +} + +function materialPosition(index: number): [number, number, number] { + return [ + -0.255 + (index % 3) * 0.255, + MATERIAL_GRID_TOP - Math.floor(index / 3) * MATERIAL_GRID_ROW_GAP, + 0, + ] +} + +function materialLabelFontSize(label: string) { + if (label.length > 16) return 0.016 + if (label.length > 11) return 0.017 + return 0.018 +} + +function MaterialTile({ + item, + index, + selected, + select, +}: { + item: MaterialCatalogItem + index: number + selected: boolean + select: () => void +}) { + return ( + + + + + {item.label} + + + ) +} + +export function XRPaintPanel() { + const categoryIndex = useXRWandPanelSettings((state) => state.paintCategoryIndex) + const page = useXRWandPanelSettings((state) => state.paintPage) + const setPaintNavigation = useXRWandPanelSettings((state) => state.setPaintNavigation) + const mode = useEditor((state) => state.mode) + const activePaintMaterial = useEditor((state) => state.activePaintMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const paintEraser = useEditor((state) => state.paintEraser) + const paintHover = useEditor((state) => state.paintHover) + const paintScope = useEditor((state) => state.paintScope) + const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) + const setPaintEraser = useEditor((state) => state.setPaintEraser) + const setPaintScope = useEditor((state) => state.setPaintScope) + const lastPaintHover = useRef(null) + if (mode !== 'material-paint') lastPaintHover.current = null + else if (paintHover) lastPaintHover.current = paintHover + const libraryVersion = useSyncExternalStore( + subscribeLibraryMaterials, + getLibraryMaterialsVersion, + getLibraryMaterialsVersion, + ) + + const availableCategories = useMemo(() => { + void libraryVersion + return MATERIAL_CATEGORIES.filter((category) => getMaterialsForCategory(category).length > 0) + }, [libraryVersion]) + const activeCategoryIndex = availableCategories.length + ? categoryIndex % availableCategories.length + : 0 + const category = (availableCategories[activeCategoryIndex] ?? + availableCategories[0] ?? + 'colors') as MaterialCategory + const materials = getMaterialsForCategory(category) + const current = getPage(materials, page, MATERIALS_PER_PAGE) + const selectedId = getLibraryMaterialIdFromRef(activePaintMaterial?.materialPreset) + const paintContext = paintHover ?? lastPaintHover.current + const paintEnabled = paintEraser || hasActivePaintMaterial(activePaintMaterial) + const availableScopes = paintContext?.scopes ?? ['single'] + const effectivePaintScope = availableScopes.includes(paintScope) ? paintScope : 'single' + const scopeLabel = !paintEnabled + ? 'Choose a material' + : paintContext + ? `Paint: ${paintScopeLabel(effectivePaintScope, paintContext)}` + : 'Aim at a surface' + + const changeCategory = (direction: -1 | 1) => { + if (availableCategories.length < 2) return + setPaintNavigation( + (activeCategoryIndex + direction + availableCategories.length) % availableCategories.length, + 0, + ) + } + + return ( + + + + changeCategory(-1)} + position={[-0.31, 0, 0]} + size={[0.075, 0.06]} + > + + ‹ + + + + {labelCategory(category)} · {activeCategoryIndex + 1}/{availableCategories.length} + + changeCategory(1)} + position={[0.31, 0, 0]} + size={[0.075, 0.06]} + > + + › + + + + + + + {mode === 'material-paint' ? 'Brush armed' : 'Start painting'} + + + { + activatePaintMode() + setPaintEraser(!paintEraser) + }} + position={[0.17, 0, 0]} + selected={paintEraser} + size={[0.3, 0.06]} + > + + Eraser + + + + {current.items.length > 0 ? ( + current.items.map((item, index) => ( + { + activatePaintMode() + setActivePaintMaterial({ + materialPreset: toLibraryMaterialRef(item.id), + sourceTarget: activePaintTarget, + }) + }} + selected={selectedId === item.id} + /> + )) + ) : ( + + No materials in this category + + )} + setPaintScope(cyclePaintScope(effectivePaintScope, availableScopes))} + position={[0, -0.265, 0]} + selected={availableScopes.length > 1 && effectivePaintScope !== 'single'} + size={[0.47, 0.055]} + > + + {scopeLabel} + + + + {current.pageCount > 1 && ( + setPaintNavigation(activeCategoryIndex, current.currentPage - 1)} + position={[-0.28, 0, 0]} + size={[0.075, 0.055]} + > + + ‹ + + + )} + + {current.items.length > 0 + ? getActivePaintMaterialLabel(activePaintMaterial) + : 'No materials'} + {current.pageCount > 1 ? ` · ${current.currentPage + 1}/${current.pageCount}` : ''} + + {current.pageCount > 1 && ( + = current.pageCount - 1} + name="xr-paint-next-page" + onClick={() => setPaintNavigation(activeCategoryIndex, current.currentPage + 1)} + position={[0.28, 0, 0]} + size={[0.075, 0.055]} + > + + › + + + )} + + + ) +} diff --git a/apps/editor/components/xr/wand-panel/panel-icon.tsx b/apps/editor/components/xr/wand-panel/panel-icon.tsx new file mode 100644 index 0000000000..07d9bd23d9 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/panel-icon.tsx @@ -0,0 +1,81 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useTexture } from '@react-three/drei' +import { Component, type ReactNode, Suspense } from 'react' +import { SRGBColorSpace } from 'three' +import { XR_WAND_THEME } from './theme' + +function TextureIcon({ size, src }: { size: number; src: string }) { + const texture = useTexture(src) + texture.colorSpace = SRGBColorSpace + return ( + undefined} + > + + + + ) +} + +function ColorIcon({ color, size }: { color: string; size: number }) { + return ( + undefined} + > + + + + ) +} + +type TextureIconBoundaryProps = { + children: ReactNode + fallback: ReactNode +} + +type TextureIconBoundaryState = { + hasError: boolean +} + +class TextureIconBoundary extends Component { + state: TextureIconBoundaryState = { hasError: false } + + static getDerivedStateFromError(): TextureIconBoundaryState { + return { hasError: true } + } + + render() { + return this.state.hasError ? this.props.fallback : this.props.children + } +} + +export function PanelIcon({ + color = XR_WAND_THEME.border, + size = 0.09, + src, +}: { + color?: string + size?: number + src?: string +}) { + if (!src) { + return + } + + const fallback = + return ( + + + + + + ) +} diff --git a/apps/editor/components/xr/wand-panel/panel-layout.ts b/apps/editor/components/xr/wand-panel/panel-layout.ts new file mode 100644 index 0000000000..1c927eac04 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/panel-layout.ts @@ -0,0 +1,52 @@ +export const XR_WAND_PANEL_LAYOUT = { + faceRadius: 0.076, + faceScale: 0.2925, + faceWidth: 0.82, + faceHeight: 1.04, + faceCornerRadius: 0.05, + faceAngles: [0, 120, 240] as const, + attachment: { + gripAxisOffset: -0.085, + gripScale: 0.66, + handSpace: 'middle-finger-metacarpal' as const, + handPosition: [0, -0.01, -0.05] as [number, number, number], + handRotation: [-0.2, 0, Math.PI] as [number, number, number], + handScale: 0.85, + }, +} as const + +export const XR_WAND_PANEL_INPUT_NAME = 'xr-editor-wand-panel' + +export function resolveWandPanelFacePose(index: number, handedness: XRHandedness = 'left') { + const angleDegrees = XR_WAND_PANEL_LAYOUT.faceAngles[index] ?? 0 + const angle = (angleDegrees * Math.PI) / 180 + const mirror = handedness === 'right' ? -1 : 1 + const radialX = Math.sin(angle) * mirror + return { + position: [ + radialX * XR_WAND_PANEL_LAYOUT.faceRadius, + Math.cos(angle) * XR_WAND_PANEL_LAYOUT.faceRadius, + 0, + ] as [number, number, number], + rotation: [-Math.PI / 2, Math.atan2(radialX, Math.cos(angle)), 0] as [number, number, number], + } +} + +export function getPage(items: readonly T[], page: number, pageSize: number) { + const pageCount = Math.max(1, Math.ceil(items.length / pageSize)) + const currentPage = Math.min(Math.max(0, page), pageCount - 1) + return { + currentPage, + pageCount, + items: items.slice(currentPage * pageSize, (currentPage + 1) * pageSize), + } +} + +export function getPageWithPinnedFirst(items: readonly T[], page: number, pageSize: number) { + const pinned = items[0] + const current = getPage(items.slice(1), page, Math.max(1, pageSize - 1)) + return { + ...current, + items: pinned === undefined ? current.items : [pinned, ...current.items], + } +} diff --git a/apps/editor/components/xr/wand-panel/settings-panel.tsx b/apps/editor/components/xr/wand-panel/settings-panel.tsx new file mode 100644 index 0000000000..7330cfc8bb --- /dev/null +++ b/apps/editor/components/xr/wand-panel/settings-panel.tsx @@ -0,0 +1,754 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + DEFAULT_LEVEL_HEIGHT, + getLevelDisplayName, + getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, + getMaterialsForCategory, + LevelNode, + MATERIAL_CATEGORIES, + type ParamAction, + type RoofNode, + RoofType as RoofTypeSchema, + subscribeLibraryMaterials, + toLibraryMaterialRef, + useRegistryVersion, + useScene, +} from '@pascal-app/core' +import { + commitParametricNodeFields, + cycleSnappingModeIn, + emitDeleteSFX, + getHistoryCommandState, + getSnappingModeLabel, + runRedo, + runUndo, + subscribeHistoryCommandState, + triggerSFX, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { + requestGodScaleReset, + toggleXRPlayerMode, + useViewer, + useXRPlayerMode, + XR_PLAYER_MODES, +} from '@pascal-app/viewer' +import { useMemo, useSyncExternalStore } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { + activateRoofFeatureTool, + activateRoofFootprintSource, + collectRoofFeatures, +} from '@/lib/build-palette' +import { getRoofFootprintSources } from '@/lib/build-tab-state' +import { + collectXRSettingRows, + createXRSettingPatch, + readXRSettingValue, + resolveXRSettingsContext, + type XRSettingActionRow, + type XRSettingFieldRow, + type XRSettingRow, + type XRSettingsContext, + type XRSettingToolChipRow, +} from '@/lib/xr/settings' +import { + useXRWandPanelSettings, + XR_WAND_PANEL_SCALE_MAX, + XR_WAND_PANEL_SCALE_MIN, + XR_WAND_PANEL_SCALE_STEP, +} from '@/lib/xr/wand-panel-settings' +import { getPage } from './panel-layout' +import { + PageArrows, + PanelHeader, + PanelHint, + SettingChoice, + SettingCycle, + SettingStepper, + SpatialButton, +} from './spatial-controls' +import { SpatialText } from './spatial-text' +import { XRTerrainSettingsPanel } from './terrain-settings-panel' +import { XR_WAND_THEME } from './theme' + +const ROWS_PER_PAGE = 5 +const DEFAULT_SETTINGS_CONTEXT_KEY = 'default-settings' +const DEFAULT_SETTINGS_PAGES = 2 +const XR_COLORS = ['#888888', '#ffffff', '#18181b', '#ef4444', '#22c55e', '#3b82f6'] + +function collectRoofActionRows(node: AnyNode): XRSettingActionRow[] { + if (node.type !== 'roof' && node.type !== 'roof-segment') return [] + const roofType = node.type === 'roof-segment' ? node.roofType : 'gable' + const rows: XRSettingActionRow[] = getRoofFootprintSources(roofType).map((source) => ({ + action: { + label: source.value === 'draw' ? 'Draw Footprint' : `Create from ${source.label}`, + onClick: () => activateRoofFootprintSource(source.value), + } satisfies ParamAction, + id: `roof-source-${source.value}`, + kind: 'action', + label: source.value === 'draw' ? 'Draw Footprint' : `Create from ${source.label}`, + })) + + rows.push({ + action: { + label: 'Draw Segment', + onClick: () => { + triggerSFX('sfx:item-pick') + const editor = useEditor.getState() + editor.setTool('roof') + if (editor.mode !== 'build') editor.setMode('build') + }, + } satisfies ParamAction, + id: 'roof-draw-segment', + kind: 'action', + label: 'Draw Segment', + }) + + for (const feature of collectRoofFeatures()) { + rows.push({ + action: { + label: `Add ${feature.label}`, + onClick: () => activateRoofFeatureTool(feature), + } satisfies ParamAction, + id: `roof-feature-${feature.id}`, + kind: 'action', + label: `Add ${feature.label}`, + }) + } + + return rows +} + +type RoofSpatialAction = { + id: string + label: string + onClick: () => void +} + +function RoofSpatialSettings({ roof }: { roof: RoofNode }) { + const setSelection = useViewer((state) => state.setSelection) + const setTool = useEditor((state) => state.setTool) + const setMode = useEditor((state) => state.setMode) + const roofDefaults = useEditor((state) => state.toolDefaults.roof) + const registryVersion = useRegistryVersion() + const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) + const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' + const actionIds = useScene( + useShallow((state) => { + const segmentIds = (roof.children ?? []).filter( + (id) => state.nodes[id as AnyNodeId]?.type === 'roof-segment', + ) + const segmentIdSet = new Set(segmentIds) + const accessoryIds = Object.values(state.nodes) + .filter((node) => node?.parentId && segmentIdSet.has(node.parentId as AnyNodeId)) + .map((node) => node!.id) + return [...segmentIds, ...accessoryIds] + }), + ) + const actions = useMemo(() => { + const nodes = useScene.getState().nodes + let segmentIndex = 0 + return actionIds.flatMap((id) => { + const node = nodes[id as AnyNodeId] + if (!node) return [] + if (node.type === 'roof-segment') { + segmentIndex += 1 + return [ + { + id: `segment-${node.id}`, + label: `Segment ${segmentIndex}: ${node.roofType}`, + onClick: () => setSelection({ selectedIds: [node.id as AnyNodeId] }), + }, + ] + } + return [ + { + id: `accessory-${node.id}`, + label: `${node.name || node.type}`, + onClick: () => setSelection({ selectedIds: [node.id as AnyNodeId] }), + }, + ] + }) + }, [actionIds, setSelection]) + const paginationKey = useXRWandPanelSettings((state) => state.settingsContextKey) + const paginationPage = useXRWandPanelSettings((state) => state.settingsPage) + const setSettingsNavigation = useXRWandPanelSettings((state) => state.setSettingsNavigation) + const rows = useMemo(() => { + void registryVersion + return [ + ...getRoofFootprintSources(roofType).map((source) => ({ + id: `draw-from-${source.value}`, + label: source.value === 'draw' ? 'Draw Footprint' : `Create from ${source.label}`, + onClick: () => activateRoofFootprintSource(source.value), + })), + { + id: 'draw-segment', + label: 'Draw Segment', + onClick: () => { + triggerSFX('sfx:item-pick') + setTool('roof') + if (useEditor.getState().mode !== 'build') setMode('build') + }, + }, + ...actions, + ...collectRoofFeatures().map((feature) => ({ + id: `add-${feature.id}`, + label: `Add ${feature.label}`, + onClick: () => { + activateRoofFeatureTool(feature) + }, + })), + ] + }, [actions, registryVersion, roofType, setMode, setTool]) + const contextKey = `node:${roof.id}:roof-actions` + const page = paginationKey === contextKey ? paginationPage : 0 + const current = getPage(rows, page, ROWS_PER_PAGE) + + return ( + <> + {current.items.map((row, index) => ( + + + + {row.label} + + + + ))} + setSettingsNavigation(contextKey, nextPage)} + page={current.currentPage} + pageCount={current.pageCount} + /> + + ) +} + +function cycleOption(options: readonly unknown[], current: unknown, direction: -1 | 1) { + if (options.length === 0) return undefined + const index = options.indexOf(current) + const base = index < 0 ? (direction === 1 ? -1 : 0) : index + return options[(base + direction + options.length) % options.length] +} + +function formatValue(value: unknown) { + if (typeof value === 'string') return value || 'None' + if (typeof value === 'boolean') return value ? 'On' : 'Off' + if (typeof value === 'number') return String(Number(value.toFixed(3))) + return value == null ? 'None' : 'Assigned' +} + +function FieldControl({ + context, + materials, + onChange, + referenceNodes, + row, +}: { + context: XRSettingsContext + materials: ReturnType + onChange: (row: XRSettingFieldRow, value: unknown) => void + referenceNodes: AnyNode[] + row: XRSettingFieldRow +}) { + let value = readXRSettingValue(context, row) + const name = `xr-setting-${String(row.field.key)}${row.axis == null ? '' : `-${row.axis}`}` + + if (row.field.kind === 'number' || row.field.kind === 'vec3') { + const min = row.field.kind === 'number' ? (row.field.min ?? -1000) : -1000 + const max = row.field.kind === 'number' ? (row.field.max ?? 1000) : 1000 + const numericValue = Math.max(min, Math.min(max, typeof value === 'number' ? value : min)) + return ( + onChange(row, next)} + step={row.field.kind === 'number' ? (row.field.step ?? 0.1) : 0.1} + unit={row.field.kind === 'number' ? row.field.unit : undefined} + value={numericValue} + /> + ) + } + if (row.field.kind === 'boolean') { + return ( + onChange(row, value !== true)} + value={value === true ? 'On' : 'Off'} + /> + ) + } + + let options: readonly unknown[] = [] + let displayValue = formatValue(value) + let mapValue = (next: unknown) => next + if (row.field.kind === 'enum') options = row.field.options + if (row.field.kind === 'color') options = XR_COLORS + if (row.field.kind === 'material') { + options = materials.map((material) => material.id) + const selectedId = getLibraryMaterialIdFromRef(value as never) + displayValue = materials.find((material) => material.id === selectedId)?.label ?? 'Default' + mapValue = (next) => toLibraryMaterialRef(String(next)) + value = selectedId + } + if (row.field.kind === 'ref') { + const refKind = row.field.refKind + const references = referenceNodes.filter((node) => node.type === refKind) + options = [null, ...references.map((node) => node.id)] + const selected = references.find((node) => node.id === value) + displayValue = selected + ? String((selected as AnyNode & { name?: string }).name ?? selected.type) + : 'None' + } + if (row.field.kind === 'custom') { + return + } + + const change = (direction: -1 | 1) => { + const next = cycleOption(options, value, direction) + if (next !== undefined) onChange(row, mapValue(next)) + } + return ( + change(1)} + previous={() => change(-1)} + value={displayValue} + /> + ) +} + +function ToolChipControl({ row }: { row: XRSettingToolChipRow }) { + const { chip } = row.hint + const value = useSyncExternalStore(chip.subscribe, chip.value, chip.value) + return ( + + ) +} + +function DefaultSettings() { + const mode = useEditor((state) => state.mode) + const interactionIdle = useInteractionScope((state) => state.scope.kind === 'idle') + const playerMode = useXRPlayerMode((state) => state.mode) + const panelScale = useXRWandPanelSettings((state) => state.panelScale) + const setPanelScale = useXRWandPanelSettings((state) => state.setPanelScale) + const gridSnapStep = useEditor((state) => state.gridSnapStep) + const cycleGridSnapStep = useEditor((state) => state.cycleGridSnapStep) + const wallSnappingMode = useEditor((state) => state.snappingModeByContext.wall) + const setSnappingMode = useEditor((state) => state.setSnappingMode) + const selectedBuildingId = useViewer((state) => state.selection.buildingId) + const activeLevelId = useViewer((state) => state.selection.levelId) + const setSelection = useViewer((state) => state.setSelection) + const createNode = useScene((state) => state.createNode) + const deleteNode = useScene((state) => state.deleteNode) + const settingsPage = useXRWandPanelSettings((state) => state.settingsPage) + const settingsContextKey = useXRWandPanelSettings((state) => state.settingsContextKey) + const setSettingsNavigation = useXRWandPanelSettings((state) => state.setSettingsNavigation) + const canUndo = useSyncExternalStore( + subscribeHistoryCommandState, + () => getHistoryCommandState().canUndo, + () => false, + ) + const canRedo = useSyncExternalStore( + subscribeHistoryCommandState, + () => getHistoryCommandState().canRedo, + () => false, + ) + const resolvedBuildingId = useScene((state) => { + if (selectedBuildingId && state.nodes[selectedBuildingId]?.type === 'building') { + return selectedBuildingId + } + return ( + Object.values(state.nodes).find((node) => node?.type === 'building') as + | BuildingNode + | undefined + )?.id + }) + const levels = useScene( + useShallow((state) => { + const building = resolvedBuildingId ? state.nodes[resolvedBuildingId] : undefined + if (building?.type !== 'building') return [] as LevelNode[] + return building.children + .map((id) => state.nodes[id]) + .filter((node): node is LevelNode => node?.type === 'level') + .sort((a, b) => a.level - b.level) + }), + ) + const activeLevel = levels.find((level) => level.id === activeLevelId) ?? levels[0] + const cycleFloor = () => { + if (!activeLevel) return + const index = levels.findIndex((level) => level.id === activeLevel.id) + const next = levels[(index + 1) % levels.length] + if (next) setSelection({ buildingId: resolvedBuildingId, levelId: next.id }) + } + + const addFloor = () => { + if (!resolvedBuildingId) return + const level = levels.length === 0 ? 0 : Math.max(...levels.map((entry) => entry.level)) + 1 + const newLevel = LevelNode.parse({ + level, + height: DEFAULT_LEVEL_HEIGHT, + children: [], + parentId: resolvedBuildingId, + }) + createNode(newLevel, resolvedBuildingId as AnyNodeId) + setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) + } + + const addBasement = () => { + if (!resolvedBuildingId) return + const level = levels.length === 0 ? -1 : Math.min(...levels.map((entry) => entry.level)) - 1 + const newLevel = LevelNode.parse({ + level, + height: DEFAULT_LEVEL_HEIGHT, + children: [], + parentId: resolvedBuildingId, + }) + createNode(newLevel, resolvedBuildingId as AnyNodeId) + setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) + } + + const removeFloor = () => { + if (!(activeLevel && activeLevel.level !== 0)) return + const index = levels.findIndex((level) => level.id === activeLevel.id) + const fallback = levels[index - 1] ?? levels[index + 1] + deleteNode(activeLevel.id) + setSelection({ + buildingId: resolvedBuildingId, + levelId: fallback?.id ?? null, + }) + } + + const page = settingsContextKey === DEFAULT_SETTINGS_CONTEXT_KEY ? settingsPage : 0 + const setPage = (nextPage: number) => + setSettingsNavigation(DEFAULT_SETTINGS_CONTEXT_KEY, nextPage) + + return ( + <> + + + Undo + + + + + Redo + + + + + Reset view + + + {page === 0 ? ( + <> + + + + + + + Add floor + + + + + Add basement + + + + + + + Remove selected floor + + + + + + + + ) : ( + <> + + + + + + + + + + + setSnappingMode('wall', cycleSnappingModeIn('wall', wallSnappingMode))} + value={getSnappingModeLabel(wallSnappingMode)} + /> + + + )} + + + ) +} + +export function XRSettingsPanel() { + const paginationKey = useXRWandPanelSettings((state) => state.settingsContextKey) + const paginationPage = useXRWandPanelSettings((state) => state.settingsPage) + const setSettingsNavigation = useXRWandPanelSettings((state) => state.setSettingsNavigation) + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const toolDefaults = useEditor((state) => + state.tool ? state.toolDefaults[state.tool] : undefined, + ) + const setToolDefaults = useEditor((state) => state.setToolDefaults) + const selectedId = useViewer((state) => + state.selection.selectedIds.length === 1 ? state.selection.selectedIds[0] : undefined, + ) + const selectedNode = useScene((state) => + selectedId ? state.nodes[selectedId as AnyNodeId] : undefined, + ) + const deleteNode = useScene((state) => state.deleteNode) + const setSelection = useViewer((state) => state.setSelection) + const nodes = useScene((state) => state.nodes) + const materialVersion = useSyncExternalStore( + subscribeLibraryMaterials, + getLibraryMaterialsVersion, + getLibraryMaterialsVersion, + ) + const materials = useMemo(() => { + void materialVersion + return MATERIAL_CATEGORIES.flatMap((category) => getMaterialsForCategory(category)) + }, [materialVersion]) + const referenceNodes = useMemo(() => Object.values(nodes).filter(Boolean) as AnyNode[], [nodes]) + const context = useMemo( + () => resolveXRSettingsContext({ mode, selectedNode, tool, toolDefaults }), + [mode, selectedNode, tool, toolDefaults], + ) + const rows = useMemo( + () => + context ? [...collectRoofActionRows(context.node), ...collectXRSettingRows(context)] : [], + [context], + ) + const contextKey = context?.key ?? 'default' + const page = paginationKey === contextKey ? paginationPage : 0 + const current = getPage(rows, page, ROWS_PER_PAGE) + const setPage = (nextPage: number) => setSettingsNavigation(contextKey, nextPage) + const deleteSelectedNode = () => { + if (!(selectedId && selectedNode && context?.source === 'node')) return + if (context.definition.capabilities.deletable === false) return + emitDeleteSFX(selectedNode.type) + setSelection({ selectedIds: [] }) + deleteNode(selectedId as AnyNodeId) + } + + const update = (row: XRSettingFieldRow, value: unknown) => { + if (!context) return + const patch = createXRSettingPatch(context, row, value) + if (context.source === 'node') { + commitParametricNodeFields(context.node.id as AnyNodeId, patch) + } else if (context.tool) { + setToolDefaults(context.tool, { ...toolDefaults, ...patch }) + } + } + + if (mode === 'terrain-sculpt') return + + return ( + + + {!context ? ( + + ) : context.node.type === 'roof' && context.source === 'node' ? ( + + ) : ( + <> + {current.items.map((row, index) => ( + + {row.kind === 'field' ? ( + + ) : row.kind === 'action' ? ( + + row.action.onClick( + useScene.getState().nodes[context.node.id as AnyNodeId] as AnyNode, + ) + } + position={[0, 0, 0]} + size={[0.7, 0.075]} + > + + {row.label} + + + ) : ( + + )} + + ))} + {rows.length === 0 && ( + No spatial settings are exposed for this item yet. + )} + {current.pageCount > 1 && ( + + )} + + )} + + ) +} diff --git a/apps/editor/components/xr/wand-panel/spatial-controls.tsx b/apps/editor/components/xr/wand-panel/spatial-controls.tsx new file mode 100644 index 0000000000..a38497cbe2 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/spatial-controls.tsx @@ -0,0 +1,476 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { DoubleSide, Shape } from 'three' +import { XR_WAND_PANEL_LAYOUT } from './panel-layout' +import { SpatialLine, shapeLinePoints } from './spatial-line' +import { SpatialText } from './spatial-text' +import { XR_WAND_THEME } from './theme' + +declare global { + var __pascalXRHoveredTarget: string | undefined + var __pascalXRLastPointerEvent: string | undefined +} + +const { accent, accentLine, border, disabled: disabledColor, muted, panel, text } = XR_WAND_THEME +const LEFT_CHEVRON = [ + [0.012, 0.018, 0.012], + [-0.012, 0, 0.012], + [0.012, -0.018, 0.012], +] as [number, number, number][] +const RIGHT_CHEVRON = LEFT_CHEVRON.map(([x, y, z]) => [-x, y, z] as [number, number, number]) + +function roundedShape(width: number, height: number, radius = 0.018) { + const shape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const r = Math.min(radius, halfWidth, halfHeight) + shape.moveTo(-halfWidth + r, -halfHeight) + shape.lineTo(halfWidth - r, -halfHeight) + shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) + shape.lineTo(halfWidth, halfHeight - r) + shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) + shape.lineTo(-halfWidth + r, halfHeight) + shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) + shape.lineTo(-halfWidth, -halfHeight + r) + shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) + shape.closePath() + return shape +} + +export function SpatialButton({ + children, + color = text, + disabled = false, + name, + onClick, + position, + selected = false, + size, +}: { + children?: ReactNode + color?: string + disabled?: boolean + name?: string + onClick?: () => void + position: [number, number, number] + selected?: boolean + size: [number, number] +}) { + const [hovered, setHovered] = useState(false) + const [pressed, setPressed] = useState(false) + const hoverLeaveTimer = useRef | null>(null) + const shape = useMemo(() => roundedShape(size[0], size[1]), [size]) + const points = useMemo(() => shapeLinePoints(shape), [shape]) + + useEffect( + () => () => { + if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current) + }, + [], + ) + + return ( + + { + event.stopPropagation() + if (process.env.NODE_ENV === 'development') { + globalThis.__pascalXRLastPointerEvent = `click:${name ?? ''}` + } + if (!disabled) onClick?.() + }} + onPointerCancel={(event) => { + event.object.releasePointerCapture?.(event.pointerId) + setPressed(false) + }} + onPointerDown={(event) => { + event.stopPropagation() + event.object.setPointerCapture?.(event.pointerId) + if (process.env.NODE_ENV === 'development') { + globalThis.__pascalXRLastPointerEvent = `down:${name ?? ''}` + } + if (!disabled) setPressed(true) + }} + onPointerEnter={() => { + if (disabled) return + if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current) + setHovered(true) + if (process.env.NODE_ENV === 'development') globalThis.__pascalXRHoveredTarget = name + }} + onPointerLeave={() => { + hoverLeaveTimer.current = setTimeout(() => setHovered(false), 75) + setPressed(false) + if (globalThis.__pascalXRHoveredTarget === name) { + globalThis.__pascalXRHoveredTarget = undefined + } + }} + onPointerUp={(event) => { + event.stopPropagation() + event.object.releasePointerCapture?.(event.pointerId) + if (process.env.NODE_ENV === 'development') { + globalThis.__pascalXRLastPointerEvent = `up:${name ?? ''}` + } + setPressed(false) + }} + position={[0, 0, 0.004]} + > + + + + + {children} + + ) +} + +export function PanelFace() { + const shape = useMemo( + () => + roundedShape( + XR_WAND_PANEL_LAYOUT.faceWidth, + XR_WAND_PANEL_LAYOUT.faceHeight, + XR_WAND_PANEL_LAYOUT.faceCornerRadius, + ), + [], + ) + const points = useMemo(() => shapeLinePoints(shape), [shape]) + return ( + <> + + + + + + + ) +} + +export function PanelHeader({ + mark, + onDelete, + title, +}: { + mark?: string + onDelete?: () => void + title: string +}) { + return ( + <> + + {title} + + {mark && ( + + {mark} + + )} + {onDelete && ( + + + Delete + + + )} + + + ) +} + +export function PanelHint({ + children, + position = [0, -0.37, 0.012], +}: { + children: ReactNode + position?: [number, number, number] +}) { + return ( + + {children} + + ) +} + +export function PageArrows({ + name, + onChange, + page, + pageCount, +}: { + name: string + onChange: (page: number) => void + page: number + pageCount: number +}) { + return ( + + onChange(page - 1)} + position={[-0.27, 0, 0]} + size={[0.1, 0.065]} + > + + + + {page + 1} / {pageCount} + + = pageCount - 1} + name={`${name}-next-page`} + onClick={() => onChange(page + 1)} + position={[0.27, 0, 0]} + size={[0.1, 0.065]} + > + = pageCount - 1 ? disabledColor : text} + lineWidth={1.5} + points={RIGHT_CHEVRON} + /> + + + ) +} + +export function SettingStepper({ + label, + max, + min, + name, + onChange, + step, + unit, + value, +}: { + label: string + max: number + min: number + name: string + onChange: (value: number) => void + step: number + unit?: string + value: number +}) { + return ( + + + {label} + + onChange(Math.max(min, value - step))} + position={[0.1, 0, 0]} + size={[0.085, 0.07]} + > + + − + + + + {Number(value.toFixed(3))} + {unit ? ` ${unit}` : ''} + + onChange(Math.min(max, value + step))} + position={[0.34, 0, 0]} + size={[0.085, 0.07]} + > + + + + + + + ) +} + +export function SettingChoice({ + label, + name, + onClick, + value, +}: { + label: string + name: string + onClick?: () => void + value: string +}) { + return ( + + + {label} + + + + {value} + + + + ) +} + +export function SettingCycle({ + label, + name, + next, + previous, + value, +}: { + label: string + name: string + next: () => void + previous: () => void + value: string +}) { + return ( + + + {label} + + + + + + {value} + + + + + + ) +} diff --git a/apps/editor/components/xr/wand-panel/spatial-line.tsx b/apps/editor/components/xr/wand-panel/spatial-line.tsx new file mode 100644 index 0000000000..0dd17fedac --- /dev/null +++ b/apps/editor/components/xr/wand-panel/spatial-line.tsx @@ -0,0 +1,53 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useEffect, useMemo } from 'react' +import { BufferGeometry, LineBasicMaterial, type Shape, Line as ThreeLine, Vector3 } from 'three' + +export function shapeLinePoints(shape: Shape) { + const points = shape.getPoints(6).map(({ x, y }) => [x, y, 0.007] as [number, number, number]) + points.push(points[0]!) + return points +} + +export function SpatialLine({ + color, + lineWidth = 1, + opacity = 1, + points, + renderOrder = 5, + transparent = false, +}: { + color: string + lineWidth?: number + opacity?: number + points: readonly [number, number, number][] + renderOrder?: number + transparent?: boolean +}) { + const line = useMemo( + () => + new ThreeLine( + new BufferGeometry().setFromPoints(points.map(([x, y, z]) => new Vector3(x, y, z))), + new LineBasicMaterial({ + color, + linewidth: lineWidth, + opacity, + transparent: transparent || opacity < 1, + }), + ), + [color, lineWidth, opacity, points, transparent], + ) + + useEffect( + () => () => { + line.geometry.dispose() + line.material.dispose() + }, + [line], + ) + + line.layers.set(EDITOR_LAYER) + line.renderOrder = renderOrder + return undefined} /> +} diff --git a/apps/editor/components/xr/wand-panel/spatial-text.tsx b/apps/editor/components/xr/wand-panel/spatial-text.tsx new file mode 100644 index 0000000000..a80feb0204 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/spatial-text.tsx @@ -0,0 +1,124 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { Children, type ReactNode, useEffect, useMemo } from 'react' +import { CanvasTexture, SRGBColorSpace } from 'three' + +const PIXELS_PER_METER = 2048 +const FONT_FAMILY = 'Inter, ui-sans-serif, system-ui, sans-serif' + +function wrapLines(context: CanvasRenderingContext2D, text: string, maxWidth?: number) { + const paragraphs = text.split('\n') + if (!maxWidth) return paragraphs + + const lines: string[] = [] + for (const paragraph of paragraphs) { + const words = paragraph.split(/\s+/).filter(Boolean) + if (words.length === 0) { + lines.push('') + continue + } + + let line = words[0]! + for (const word of words.slice(1)) { + const candidate = `${line} ${word}` + if (context.measureText(candidate).width <= maxWidth) line = candidate + else { + lines.push(line) + line = word + } + } + lines.push(line) + } + return lines +} + +export function SpatialText({ + anchorX = 'center', + anchorY = 'middle', + children, + color, + fontSize, + maxWidth, + position, + renderOrder = 6, + textAlign = 'center', +}: { + anchorX?: 'center' | 'left' | 'right' + anchorY?: 'bottom' | 'middle' | 'top' + children: ReactNode + color: string + fontSize: number + maxWidth?: number + position: [number, number, number] + renderOrder?: number + textAlign?: 'center' | 'left' | 'right' +}) { + const text = Children.toArray(children).join('') + const rendered = useMemo(() => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + if (!context) return null + + const fontPixels = Math.max(12, Math.round(fontSize * PIXELS_PER_METER)) + const lineHeight = Math.ceil(fontPixels * 1.2) + const padding = Math.ceil(fontPixels * 0.12) + context.font = `600 ${fontPixels}px ${FONT_FAMILY}` + const lines = wrapLines( + context, + text, + maxWidth ? Math.round(maxWidth * PIXELS_PER_METER) : undefined, + ) + const measuredWidth = Math.max(1, ...lines.map((line) => context.measureText(line).width)) + canvas.width = Math.ceil(measuredWidth + padding * 2) + canvas.height = Math.ceil(lines.length * lineHeight + padding * 2) + + context.font = `600 ${fontPixels}px ${FONT_FAMILY}` + context.fillStyle = color + context.textAlign = textAlign + context.textBaseline = 'top' + const x = + textAlign === 'left' + ? padding + : textAlign === 'right' + ? canvas.width - padding + : canvas.width / 2 + lines.forEach((line, index) => { + context.fillText(line, x, padding + index * lineHeight) + }) + + const texture = new CanvasTexture(canvas) + texture.colorSpace = SRGBColorSpace + return { + height: canvas.height / PIXELS_PER_METER, + texture, + width: canvas.width / PIXELS_PER_METER, + } + }, [color, fontSize, maxWidth, text, textAlign]) + + useEffect(() => () => rendered?.texture.dispose(), [rendered]) + if (!rendered) return null + + const offsetX = + anchorX === 'left' ? rendered.width / 2 : anchorX === 'right' ? -rendered.width / 2 : 0 + const offsetY = + anchorY === 'top' ? -rendered.height / 2 : anchorY === 'bottom' ? rendered.height / 2 : 0 + + return ( + undefined} + > + + + + ) +} diff --git a/apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx b/apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx new file mode 100644 index 0000000000..cc0af3b803 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx @@ -0,0 +1,146 @@ +'use client' + +import { type SiteNode, type TerrainVerb, useScene } from '@pascal-app/core' +import { brushRadiusRange, flattenSite, resetSiteTerrain, useEditor } from '@pascal-app/editor' +import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' +import { getPage } from './panel-layout' +import { + PageArrows, + PanelHeader, + SettingChoice, + SettingCycle, + SettingStepper, +} from './spatial-controls' + +const TERRAIN_VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth'] +const ROWS_PER_PAGE = 5 + +export function XRTerrainSettingsPanel() { + const page = useXRWandPanelSettings((state) => state.terrainPage) + const setPage = useXRWandPanelSettings((state) => state.setTerrainPage) + const verb = useEditor((state) => state.terrainVerb) + const setVerb = useEditor((state) => state.setTerrainVerb) + const brush = useEditor((state) => state.terrainBrush) + const setBrush = useEditor((state) => state.setTerrainBrush) + const flattenTarget = useEditor((state) => state.terrainFlattenTarget) + const setFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget) + const sampling = useEditor((state) => state.terrainSampling) + const setSampling = useEditor((state) => state.setTerrainSampling) + const site = useScene((state) => { + const root = state.rootNodeIds[0] + const node = root ? state.nodes[root] : undefined + return node?.type === 'site' ? (node as SiteNode) : null + }) + const [minRadius, maxRadius] = brushRadiusRange(site) + const verbIndex = TERRAIN_VERBS.indexOf(verb) + const cycleVerb = (direction: -1 | 1) => { + const next = + TERRAIN_VERBS[(verbIndex + direction + TERRAIN_VERBS.length) % TERRAIN_VERBS.length] + if (next) setVerb(next) + } + + const rows = [ + cycleVerb(1)} + previous={() => cycleVerb(-1)} + value={verb.charAt(0).toUpperCase() + verb.slice(1)} + />, + setBrush({ radius })} + step={0.5} + unit="m" + value={Math.max(minRadius, Math.min(maxRadius, brush.radius))} + />, + setBrush({ strength })} + step={0.05} + value={brush.strength} + />, + setBrush({ falloff })} + step={0.05} + value={brush.falloff} + />, + setBrush({ shape: brush.shape === 'round' ? 'square' : 'round' })} + value={brush.shape === 'round' ? 'Round' : 'Square'} + />, + ...(verb === 'flatten' + ? [ + , + setSampling(!sampling)} + value={sampling ? 'Armed' : 'Off'} + />, + ] + : []), + flattenSite(site, flattenTarget ?? 0) : undefined} + value="Level" + />, + resetSiteTerrain(site) : undefined} + value={site?.terrain ? 'Clear' : 'Empty'} + />, + ] + const current = getPage(rows, page, ROWS_PER_PAGE) + + return ( + + + {current.items.map((row, index) => ( + + {row} + + ))} + {current.pageCount > 1 && ( + + )} + + ) +} diff --git a/apps/editor/components/xr/wand-panel/theme.ts b/apps/editor/components/xr/wand-panel/theme.ts new file mode 100644 index 0000000000..f8f514102b --- /dev/null +++ b/apps/editor/components/xr/wand-panel/theme.ts @@ -0,0 +1,9 @@ +export const XR_WAND_THEME = { + accent: '#0ea5e9', + accentLine: '#38bdf8', + border: '#52525b', + disabled: '#52525b', + muted: '#a1a1aa', + panel: '#171717', + text: '#fafafa', +} as const diff --git a/apps/editor/components/xr/wand-panel/wand-panel.tsx b/apps/editor/components/xr/wand-panel/wand-panel.tsx new file mode 100644 index 0000000000..d24a26bc77 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/wand-panel.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' +import { XRBuildPanel } from './build-panel' +import { XRPaintPanel } from './paint-panel' +import { + resolveWandPanelFacePose, + XR_WAND_PANEL_INPUT_NAME, + XR_WAND_PANEL_LAYOUT, +} from './panel-layout' +import { XRSettingsPanel } from './settings-panel' +import { PanelFace } from './spatial-controls' + +export function XRWandPanel({ handedness = 'left' }: { handedness?: XRHandedness }) { + const panelScale = useXRWandPanelSettings((state) => state.panelScale) + const panels = [ + , + , + , + ] + + return ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerOver={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + pointerEventsOrder={100} + pointerEventsType={{ deny: 'grab' }} + scale={panelScale} + > + {panels.map((panel, index) => { + const pose = resolveWandPanelFacePose(index, handedness) + return ( + + + {panel} + + ) + })} + + ) +} diff --git a/apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx b/apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx new file mode 100644 index 0000000000..e4a8f1d867 --- /dev/null +++ b/apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useXRInputSourceStateContext, XRSpace } from '@react-three/xr' +import { XR_WAND_PANEL_LAYOUT } from './panel-layout' +import { XRWandPanel } from './wand-panel' + +export function XRWandInputOverlay({ type }: { type: 'controller' | 'hand' }) { + const state = useXRInputSourceStateContext(type) + const handedness = state.inputSource.handedness + if (handedness !== 'left') return null + + if (type === 'controller') { + return ( + + + + + + ) + } + + return ( + + + + + + ) +} diff --git a/apps/editor/components/xr/xr-editor-input-bridge.tsx b/apps/editor/components/xr/xr-editor-input-bridge.tsx new file mode 100644 index 0000000000..f61baacdda --- /dev/null +++ b/apps/editor/components/xr/xr-editor-input-bridge.tsx @@ -0,0 +1,721 @@ +'use client' + +import { + type AnyNodeId, + advanceStroke, + applyHeightPatch, + beginStroke, + type EventSuffix, + emitter, + type GridEvent, + minBrushRadius, + type NodeEvent, + raycastTerrain, + type SiteNode, + sceneRegistry, + surfaceHeightAt, + type TerrainField, + type TerrainStroke, + terrainFieldOf, + useLiveTerrain, + useScene, + type WallEvent, +} from '@pascal-app/core' +import { + cancelActiveTool, + canDirectMoveNode, + clipTerrainPatchToSite, + commitStroke, + createEditorApi, + EDITOR_GRID_INPUT_NAME, + getSpatialPointerId, + resolveFlattenTarget, + sculptFieldForSite, + spatialPointerInput, + terrainPointInsideSite, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useFrame, useThree } from '@react-three/fiber' +import { useXR } from '@react-three/xr' +import { type MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + Line, + LineBasicMaterial, + type Object3D, + Plane, + Quaternion, + Raycaster, + Vector3, +} from 'three' +import { + didXRButtonPressStart, + isXRCancelPressed, + pulseXRInputSource, + replayXRWallOpeningRelease, + resolveXRReleaseAction, + selectPrimaryXRInputSource, + shouldReleaseCapturedXRInput, + shouldRouteXRMove, + XRSelectReleaseGuard, +} from '@/lib/xr/editor-input' +import { applyXRReferenceSpaceRayToWorld, setObjectFloorPlane } from '@/lib/xr/reference-space-ray' +import { XR_WAND_PANEL_INPUT_NAME } from './wand-panel/panel-layout' + +type XRGridNativeEvent = { + altKey: false + button: 0 + buttons: number + ctrlKey: false + detail: number + metaKey: false + pointerId: number + pointerType: 'xr' + shiftKey: false + stopImmediatePropagation: () => void + stopPropagation: () => void + target: HTMLCanvasElement + timeStamp: number +} + +type XRTerrainFocus = { radius: number; siteId: SiteNode['id']; x: number; z: number } +const TERRAIN_RING_SEGMENTS = 64 + +const xrInputSourceKey = (source: XRInputSource) => + `${source.handedness}:${source.targetRayMode}:${Boolean(source.hand)}` + +const sameXRInputSource = (a: XRInputSource | null, b: XRInputSource | null) => + a === b || (a != null && b != null && xrInputSourceKey(a) === xrInputSourceKey(b)) + +function XRTerrainBrushCursor({ focusRef }: { focusRef: MutableRefObject }) { + const mode = useEditor((state) => state.mode) + const shape = useEditor((state) => state.terrainBrush.shape) + const verb = useEditor((state) => state.terrainVerb) + const geometry = useMemo(() => { + const result = new BufferGeometry() + result.setAttribute( + 'position', + new Float32BufferAttribute(new Float32Array((TERRAIN_RING_SEGMENTS + 1) * 3), 3), + ) + return result + }, []) + const line = useMemo(() => { + const result = new Line( + geometry, + new LineBasicMaterial({ color: '#38bdf8', depthTest: false, depthWrite: false }), + ) + result.frustumCulled = false + result.name = 'xr-terrain-brush-cursor' + result.raycast = () => undefined + result.renderOrder = 30 + return result + }, [geometry]) + + useEffect( + () => () => { + geometry.dispose() + line.material.dispose() + }, + [geometry, line], + ) + useFrame(() => { + const focus = focusRef.current + line.visible = mode === 'terrain-sculpt' && focus !== null + if (!(line.visible && focus)) return + const site = useScene.getState().nodes[focus.siteId] + if (site?.type !== 'site') return + const field = + useLiveTerrain.getState().strokeOf(site.id)?.field ?? + terrainFieldOf(site) ?? + sculptFieldForSite(site) + const positions = geometry.getAttribute('position') + for (let index = 0; index <= TERRAIN_RING_SEGMENTS; index += 1) { + const angle = (index / TERRAIN_RING_SEGMENTS) * Math.PI * 2 + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const scale = + shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1 + const x = focus.x + cos * focus.radius * scale + const z = focus.z + sin * focus.radius * scale + positions.setXYZ(index, x, surfaceHeightAt(field, x, z) + 0.02, z) + } + positions.needsUpdate = true + geometry.computeBoundingSphere() + line.material.color.set(verb === 'raise' ? '#22c55e' : verb === 'lower' ? '#ef4444' : '#38bdf8') + }) + + return +} + +function isXRNodePointer(event: NodeEvent): boolean { + return getSpatialPointerId(event.nativeEvent) != null +} + +export function XREditorInputBridge() { + const session = useXR((state) => state.session) + const origin = useXR((state) => state.origin) + const scene = useThree((state) => state.scene) + const gl = useThree((state) => state.gl) + // Logical XR pointer capture: the source that starts a scene press owns its + // move/up stream until selectend, even when its ray crosses the wand. + const capturedInputSource = useRef(null) + const lastXRWallEvent = useRef(null) + const lastSyntheticWallEvent = useRef(null) + const terrainInputSources = useRef(new Set()) + const terrainStroke = useRef<{ + field: TerrainField + siteId: SiteNode['id'] + source: XRInputSource + stroke: TerrainStroke + } | null>(null) + const terrainFocus = useRef(null) + const panelInputSources = useRef(new Set()) + const cancelPressed = useRef(false) + const pointerIds = useRef(new WeakMap()) + const nextPointerId = useRef(10_000) + const raycaster = useRef(new Raycaster()) + const rayOrigin = useRef(new Vector3()) + const rayDirection = useRef(new Vector3()) + const rayRotation = useRef(new Quaternion()) + const gridPlane = useRef(new Plane()) + const gridPlaneNormal = useRef(new Vector3()) + const gridPlanePoint = useRef(new Vector3()) + const selectReleaseGuard = useRef(new XRSelectReleaseGuard()) + + const activeSite = useCallback(() => { + const state = useScene.getState() + const node = state.rootNodeIds[0] ? state.nodes[state.rootNodeIds[0]] : undefined + return node?.type === 'site' ? (node as SiteNode) : null + }, []) + + const pointerIdFor = useCallback((source: XRInputSource) => { + const existing = pointerIds.current.get(source) + if (existing !== undefined) return existing + const next = nextPointerId.current++ + pointerIds.current.set(source, next) + return next + }, []) + + const updateRay = useCallback( + (frame: XRFrame, source: XRInputSource): boolean => { + const referenceSpace = gl.xr.getReferenceSpace() + if (!referenceSpace) return false + const pose = frame.getPose(source.targetRaySpace, referenceSpace) + if (!(origin && pose)) return false + const { position, orientation } = pose.transform + origin.updateWorldMatrix(true, false) + rayOrigin.current.set(position.x, position.y, position.z) + rayRotation.current.set(orientation.x, orientation.y, orientation.z, orientation.w) + rayDirection.current.set(0, 0, -1).applyQuaternion(rayRotation.current) + applyXRReferenceSpaceRayToWorld(rayOrigin.current, rayDirection.current, origin.matrixWorld) + raycaster.current.ray.set(rayOrigin.current, rayDirection.current) + raycaster.current.layers.enableAll() + return true + }, + [gl, origin], + ) + + const isWandPanelHit = useCallback( + (frame: XRFrame, source: XRInputSource): boolean => { + const panel = scene.getObjectByName(XR_WAND_PANEL_INPUT_NAME) + if (!(panel && updateRay(frame, source))) return false + panel.updateWorldMatrix(true, true) + return raycaster.current.intersectObject(panel, true).length > 0 + }, + [scene, updateRay], + ) + + const terrainPoint = useCallback( + (frame: XRFrame, source: XRInputSource, field: TerrainField, site: SiteNode) => { + if (!updateRay(frame, source)) return null + const origin = rayOrigin.current + const direction = rayDirection.current + const hit = raycastTerrain( + field, + [origin.x, origin.y, origin.z], + [direction.x, direction.y, direction.z], + ) + if (hit && terrainPointInsideSite(site, hit.x, hit.z)) return [hit.x, hit.z] as const + + // A site without persisted terrain has an implicit ground plane. Keep XR + // strokes usable before the first terrain sample exists; the terrain + // raycast only covers the finite heightfield once it has a valid hit. + if (Math.abs(direction.y) < 1e-6) return null + const t = -origin.y / direction.y + if (t < 0) return null + const x = origin.x + direction.x * t + const z = origin.z + direction.z * t + return terrainPointInsideSite(site, x, z) ? ([x, z] as const) : null + }, + [updateRay], + ) + + const abandonTerrainStroke = useCallback(() => { + const active = terrainStroke.current + if (!active) return false + terrainStroke.current = null + useLiveTerrain.getState().end(active.siteId) + return true + }, []) + + const applyTerrainDab = useCallback( + (frame: XRFrame, source: XRInputSource) => { + const active = terrainStroke.current + const site = activeSite() + if (!(active && sameXRInputSource(active.source, source) && site?.id === active.siteId)) + return false + const point = terrainPoint(frame, source, active.stroke.snapshot, site) + if (!point) return false + terrainFocus.current = { + radius: active.stroke.settings.radius, + siteId: site.id, + x: point[0], + z: point[1], + } + const brushPatch = advanceStroke(active.stroke, point[0], point[1]) + if (!brushPatch) return false + const patch = clipTerrainPatchToSite(active.field, brushPatch, site) + active.field = applyHeightPatch(active.field, patch) + useLiveTerrain.getState().advance(active.siteId, active.field, patch) + return true + }, + [activeSite, terrainPoint], + ) + + const startTerrainStroke = useCallback( + (frame: XRFrame, source: XRInputSource) => { + const site = activeSite() + if (!site) return false + const editor = useEditor.getState() + const field = sculptFieldForSite(site) + const point = terrainPoint(frame, source, field, site) + if (!point) return false + if (editor.terrainSampling) { + editor.setTerrainFlattenTarget(resolveFlattenTarget(field, null, point[0], point[1])) + return true + } + const stroke = beginStroke({ + field, + settings: { + ...editor.terrainBrush, + radius: Math.max(editor.terrainBrush.radius, minBrushRadius(field)), + }, + target: + editor.terrainVerb === 'flatten' + ? resolveFlattenTarget(field, editor.terrainFlattenTarget, point[0], point[1]) + : undefined, + verb: editor.terrainVerb, + }) + terrainStroke.current = { field, siteId: site.id, source, stroke } + useLiveTerrain.getState().begin(site.id, field) + applyTerrainDab(frame, source) + return true + }, + [activeSite, applyTerrainDab, terrainPoint], + ) + + const finishTerrainStroke = useCallback((source: XRInputSource) => { + const active = terrainStroke.current + if (!(active && sameXRInputSource(active.source, source))) return false + terrainStroke.current = null + commitStroke(active.siteId, active.field) + useLiveTerrain.getState().end(active.siteId) + return true + }, []) + + const createGridEvent = useCallback( + ( + frame: XRFrame, + source: XRInputSource, + buttons: number, + allowRayFallback = false, + ): GridEvent | null => { + const grid = scene.getObjectByName(EDITOR_GRID_INPUT_NAME) + if (!updateRay(frame, source)) return null + grid?.updateWorldMatrix(true, false) + + const selection = useViewer.getState().selection + const levelMesh = selection.levelId + ? sceneRegistry.nodes.get(selection.levelId as AnyNodeId) + : null + levelMesh?.updateWorldMatrix(true, false) + let levelFloorPoint: Vector3 | null = null + if (levelMesh) { + setObjectFloorPlane( + gridPlane.current, + levelMesh.matrixWorld, + gridPlanePoint.current, + gridPlaneNormal.current, + ) + levelFloorPoint = raycaster.current.ray.intersectPlane(gridPlane.current, new Vector3()) + } + const hit = + !levelFloorPoint && grid?.visible + ? raycaster.current.intersectObject(grid, false)[0] + : undefined + if (!(levelFloorPoint || hit || allowRayFallback)) return null + + const worldPoint = levelFloorPoint ?? hit?.point ?? raycaster.current.ray.at(1, new Vector3()) + const buildingId = selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + const localPoint = buildingMesh + ? buildingMesh.worldToLocal(worldPoint.clone()) + : worldPoint.clone() + const nativeEvent: XRGridNativeEvent = { + altKey: false, + button: 0, + buttons, + ctrlKey: false, + detail: 1, + metaKey: false, + pointerId: pointerIdFor(source), + pointerType: 'xr', + shiftKey: false, + stopImmediatePropagation: () => undefined, + stopPropagation: () => undefined, + target: gl.domElement, + timeStamp: performance.now(), + } + return { + localPosition: [localPoint.x, localPoint.y, localPoint.z], + nativeEvent: nativeEvent as never, + position: [worldPoint.x, worldPoint.y, worldPoint.z], + } + }, + [gl, pointerIdFor, scene, updateRay], + ) + + const emitGridEvent = useCallback( + (suffix: EventSuffix, frame: XRFrame, source: XRInputSource, buttons: number): boolean => { + const payload = createGridEvent(frame, source, buttons) + if (!payload) return false + emitter.emit(`grid:${suffix}` as `grid:${EventSuffix}`, payload) + return true + }, + [createGridEvent], + ) + + const emitWallOpeningHover = useCallback( + (frame: XRFrame, source: XRInputSource): boolean => { + if (!updateRay(frame, source)) return false + + let nearest: + | { + distance: number + event: WallEvent + } + | undefined + const nativeEvent = { + button: 0, + buttons: 0, + inputSource: source, + openingHoverBridge: true, + pointerId: pointerIdFor(source), + pointerType: 'xr', + stopImmediatePropagation: () => undefined, + stopPropagation: () => undefined, + target: gl.domElement, + timeStamp: performance.now(), + } + const registeredObjects = new Set(sceneRegistry.nodes.values()) + + for (const node of Object.values(useScene.getState().nodes)) { + if (node?.type !== 'wall') continue + const object = sceneRegistry.nodes.get(node.id) + if (!object) continue + object.updateWorldMatrix(true, true) + const hit = raycaster.current.intersectObject(object, true).find((intersection) => { + let current: Object3D | null = intersection.object + while (current && current !== object) { + if (registeredObjects.has(current)) return false + current = current.parent + } + return current === object + }) + if (!(hit?.face && (!nearest || hit.distance < nearest.distance))) continue + const localPoint = object.worldToLocal(hit.point.clone()) + nearest = { + distance: hit.distance, + event: { + localPosition: [localPoint.x, localPoint.y, localPoint.z], + nativeEvent: nativeEvent as never, + node, + normal: [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z], + object: hit.object, + position: [hit.point.x, hit.point.y, hit.point.z], + stopPropagation: () => undefined, + }, + } + } + + if (!nearest) { + const previous = lastSyntheticWallEvent.current + if (previous) emitter.emit('wall:leave', previous) + lastSyntheticWallEvent.current = null + return false + } + + lastSyntheticWallEvent.current = nearest.event + emitter.emit('wall:move', nearest.event) + return true + }, + [gl, pointerIdFor, updateRay], + ) + + const dispatchWindowPointerEvent = useCallback( + (type: 'pointerup' | 'pointercancel', source: XRInputSource) => { + window.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: pointerIdFor(source), + pointerType: 'xr', + }), + ) + }, + [pointerIdFor], + ) + + useEffect(() => { + const onNodePointerDown = (event: NodeEvent) => { + if (!isXRNodePointer(event)) return + if (useEditor.getState().mode !== 'select') return + if (useInteractionScope.getState().scope.kind !== 'idle') return + + const selectedIds = useViewer.getState().selection.selectedIds + if (!(selectedIds.length === 1 && selectedIds[0] === event.node.id)) return + if (!canDirectMoveNode(event.node)) return + + event.stopPropagation() + useViewer.getState().setInputDragging(true) + createEditorApi().engageMoveDrag(event.node) + } + const onNodeClick = (event: NodeEvent) => { + if (!isXRNodePointer(event)) return + const source = getSpatialPointerId(event.nativeEvent) + if (typeof source === 'object') { + selectReleaseGuard.current.markNodeClick(source as XRInputSource) + } + } + + emitter.on('node:pointerdown', onNodePointerDown) + emitter.on('node:click', onNodeClick) + return () => { + emitter.off('node:pointerdown', onNodePointerDown) + emitter.off('node:click', onNodeClick) + } + }, []) + + useEffect(() => { + if (!session) return + + const rememberXRWallEvent = (event: WallEvent) => { + lastXRWallEvent.current = event + } + const clearXRWallEvent = (event: WallEvent) => { + if (capturedInputSource.current != null) return + lastXRWallEvent.current = null + } + emitter.on('wall:enter', rememberXRWallEvent) + emitter.on('wall:move', rememberXRWallEvent) + emitter.on('wall:leave', clearXRWallEvent) + + const onSelectStart = (event: XRInputSourceEvent) => { + selectReleaseGuard.current.start(event.inputSource) + if (isWandPanelHit(event.frame, event.inputSource)) { + panelInputSources.current.add(xrInputSourceKey(event.inputSource)) + pulseXRInputSource(event.inputSource, 0.1, 20) + return + } + capturedInputSource.current = event.inputSource + pulseXRInputSource(event.inputSource) + if (useEditor.getState().mode === 'terrain-sculpt') { + terrainInputSources.current.add(xrInputSourceKey(event.inputSource)) + startTerrainStroke(event.frame, event.inputSource) + return + } + emitGridEvent('pointerdown', event.frame, event.inputSource, 1) + } + const onSelectEnd = (event: XRInputSourceEvent) => { + const releaseMode = useEditor.getState().mode + const releaseTool = useEditor.getState().tool + const wallOpeningToolActive = + releaseMode === 'build' && (releaseTool === 'door' || releaseTool === 'window') + if (panelInputSources.current.delete(xrInputSourceKey(event.inputSource))) { + selectReleaseGuard.current.cancel(event.inputSource) + return + } + if (releaseMode === 'terrain-sculpt') { + terrainInputSources.current.delete(xrInputSourceKey(event.inputSource)) + finishTerrainStroke(event.inputSource) + selectReleaseGuard.current.cancel(event.inputSource) + capturedInputSource.current = null + return + } + if ( + !sameXRInputSource(capturedInputSource.current, event.inputSource) && + !wallOpeningToolActive + ) { + selectReleaseGuard.current.cancel(event.inputSource) + return + } + + const handledSpatialRelease = spatialPointerInput.release(event.inputSource) + + const pressDrag = useEditor.getState().placementDragMode + const mode = releaseMode + const scope = useInteractionScope.getState().scope + const releaseAction = resolveXRReleaseAction({ + mode, + placementDrag: pressDrag, + scopeKind: scope.kind, + }) + const emptySelectionEvent = + releaseAction === 'defer-empty-selection' + ? createGridEvent(event.frame, event.inputSource, 0, true) + : null + pulseXRInputSource(event.inputSource, 0.08, 18) + emitGridEvent('pointerup', event.frame, event.inputSource, 0) + dispatchWindowPointerEvent('pointerup', event.inputSource) + + if (wallOpeningToolActive && lastXRWallEvent.current) { + replayXRWallOpeningRelease(lastXRWallEvent.current, (suffix, wallEvent) => { + if (suffix === 'move') emitter.emit('wall:move', wallEvent) + else emitter.emit('wall:click', wallEvent) + }) + lastXRWallEvent.current = null + } + + if (handledSpatialRelease && !wallOpeningToolActive) { + selectReleaseGuard.current.cancel(event.inputSource) + } else if (releaseAction === 'finish-placement-drag') { + useViewer.getState().setInputDragging(false) + selectReleaseGuard.current.cancel(event.inputSource) + } else if (releaseAction === 'emit-tool-grid-click') { + emitGridEvent('click', event.frame, event.inputSource, 0) + selectReleaseGuard.current.cancel(event.inputSource) + } else if (releaseAction === 'defer-empty-selection' && emptySelectionEvent) { + selectReleaseGuard.current.deferEmptyRelease(event.inputSource, () => { + if (useEditor.getState().mode !== 'select') return + if (useInteractionScope.getState().scope.kind !== 'idle') return + if (useViewer.getState().inputDragging) return + emitter.emit('grid:click', emptySelectionEvent) + }) + } else { + selectReleaseGuard.current.cancel(event.inputSource) + } + + capturedInputSource.current = null + } + const onSelectCancel = (event: XRInputSourceEvent) => { + if (panelInputSources.current.delete(xrInputSourceKey(event.inputSource))) { + selectReleaseGuard.current.cancel(event.inputSource) + return + } + if (terrainInputSources.current.delete(xrInputSourceKey(event.inputSource))) { + abandonTerrainStroke() + selectReleaseGuard.current.cancel(event.inputSource) + capturedInputSource.current = null + return + } + if (!sameXRInputSource(capturedInputSource.current, event.inputSource)) { + selectReleaseGuard.current.cancel(event.inputSource) + return + } + + const handledSpatialCancel = spatialPointerInput.cancel(event.inputSource) + emitGridEvent('pointerup', event.frame, event.inputSource, 0) + dispatchWindowPointerEvent('pointercancel', event.inputSource) + if (!handledSpatialCancel && useEditor.getState().placementDragMode) { + useViewer.getState().setInputDragging(false) + } + selectReleaseGuard.current.cancel(event.inputSource) + capturedInputSource.current = null + } + + session.addEventListener('selectstart', onSelectStart) + session.addEventListener('selectend', onSelectEnd) + session.addEventListener('selectcancel', onSelectCancel as unknown as EventListener) + return () => { + session.removeEventListener('selectstart', onSelectStart) + session.removeEventListener('selectend', onSelectEnd) + session.removeEventListener('selectcancel', onSelectCancel as unknown as EventListener) + abandonTerrainStroke() + emitter.off('wall:enter', rememberXRWallEvent) + emitter.off('wall:move', rememberXRWallEvent) + emitter.off('wall:leave', clearXRWallEvent) + } + }, [ + abandonTerrainStroke, + createGridEvent, + dispatchWindowPointerEvent, + emitGridEvent, + finishTerrainStroke, + isWandPanelHit, + session, + startTerrainStroke, + ]) + + useFrame((_, __, frame) => { + if (!(frame && session)) return + const inputSources = Array.from(session.inputSources) + if (shouldReleaseCapturedXRInput(inputSources, capturedInputSource.current)) { + spatialPointerInput.cancel(capturedInputSource.current!) + dispatchWindowPointerEvent('pointercancel', capturedInputSource.current!) + if (useEditor.getState().placementDragMode) { + useViewer.getState().setInputDragging(false) + } + selectReleaseGuard.current.cancel(capturedInputSource.current!) + capturedInputSource.current = null + } + const source = selectPrimaryXRInputSource(inputSources, capturedInputSource.current) + const panelHit = source ? isWandPanelHit(frame, source) : false + if (source && useEditor.getState().mode === 'terrain-sculpt' && !panelHit) { + const site = activeSite() + if (site) { + const field = terrainStroke.current?.stroke.snapshot ?? sculptFieldForSite(site) + const point = terrainPoint(frame, source, field, site) + const radius = Math.max(useEditor.getState().terrainBrush.radius, minBrushRadius(field)) + terrainFocus.current = point ? { radius, siteId: site.id, x: point[0], z: point[1] } : null + } + } else if (useEditor.getState().mode !== 'terrain-sculpt' || panelHit) { + terrainFocus.current = null + } + if ( + source && + shouldRouteXRMove(source, capturedInputSource.current, panelHit) && + (capturedInputSource.current == null || + sameXRInputSource(capturedInputSource.current, source)) + ) { + if (useEditor.getState().mode === 'terrain-sculpt') { + if (capturedInputSource.current === source) applyTerrainDab(frame, source) + } else { + emitGridEvent('move', frame, source, capturedInputSource.current ? 1 : 0) + const editor = useEditor.getState() + if (editor.mode === 'build' && (editor.tool === 'door' || editor.tool === 'window')) { + emitWallOpeningHover(frame, source) + } else { + lastSyntheticWallEvent.current = null + } + spatialPointerInput.move(source, raycaster.current.ray) + } + } + + const nextCancelPressed = isXRCancelPressed(inputSources) + if (didXRButtonPressStart(cancelPressed.current, nextCancelPressed)) { + abandonTerrainStroke() + cancelActiveTool() + const rightController = inputSources.find( + (inputSource) => inputSource.handedness === 'right' && inputSource.gamepad != null, + ) + if (rightController) pulseXRInputSource(rightController, 0.25, 35) + useViewer.getState().setInputDragging(false) + } + cancelPressed.current = nextCancelPressed + }) + + return +} diff --git a/apps/editor/components/xr/xr-emulator-test-harness.tsx b/apps/editor/components/xr/xr-emulator-test-harness.tsx new file mode 100644 index 0000000000..4f01cde4e6 --- /dev/null +++ b/apps/editor/components/xr/xr-emulator-test-harness.tsx @@ -0,0 +1,779 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + type GridEvent, + type NodeEvent, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { getHistoryCommandState, useEditor, useInteractionScope } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useXR } from '@react-three/xr' +import { useEffect } from 'react' +import { Box3, Object3D, Quaternion, Raycaster, Vector3 } from 'three' +import { getEmulatedXRDevice } from '@/lib/xr/emulator' +import { resolveEmulatedInputPose } from '@/lib/xr/emulator-ray' +import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' + +type InputKind = 'controller' | 'hand' + +const XR_INPUT_EVENT_TIMEOUT_MS = 150 +const XR_FRAME_TIMEOUT_MS = 100 + +export type XREmulatorTestHarness = { + aimAt: (name: string, inputKind?: InputKind) => Promise + aimAtNode: (nodeId: string, inputKind?: InputKind, distance?: number) => Promise + click: (name: string, inputKind?: InputKind) => Promise + clickLevelPoint: (point: [number, number], inputKind?: InputKind) => Promise + clickNode: (nodeId: string, inputKind?: InputKind) => Promise + clickNodeSurface: (nodeId: string, inputKind?: InputKind) => Promise + drag: (names: string[], inputKind?: InputKind) => Promise + dragNodeTo: ( + nodeId: string, + worldPoint: [number, number, number], + inputKind?: InputKind, + ) => Promise + listSceneNodes: () => { id: string; parentId: string | null; type: string }[] + listSpatialTargets: () => string[] + panGodView: (delta: [number, number, number]) => Promise + placeToolOnGrid: ( + toolTarget: string, + nodeType: string, + points: [number, number][], + inputKind?: InputKind, + ) => Promise<{ + activated: boolean + cancelled: boolean + createdNodeIds: string[] + deliveredPoints: number + }> + placeToolOnNode: ( + toolTarget: string, + hostNodeId: string, + nodeType: string, + inputKind?: InputKind, + ) => Promise<{ + activated: boolean + attachedToHost: boolean + cancelled: boolean + createdNodeIds: string[] + deliveredHostClick: boolean + }> + probe: (name: string, inputKind?: InputKind) => Promise> + probeNode: ( + nodeId: string, + inputKind?: InputKind, + distance?: number, + ) => Promise> + readNode: (nodeId: string) => AnyNode | undefined + sculptLevelPoints: (points: [number, number][], inputKind?: InputKind) => Promise + snapshot: () => { + activePaintMaterial: string | null + hoveredTarget?: string + history: { canRedo: boolean; canUndo: boolean; mode: string; status: string } + godViewTransform: { position: number[]; rotationY: number; scale: number[] } | null + lastGridEvent?: string + lastNodeEvent?: string + lastPointerEvent?: string + levelId: string | null + mode: string + nodeCounts: Record + paintEraser: boolean + paintHover: { nodeNoun: string; scopes: string[]; slotLabel: string } | null + paintScope: string + terrainBrush: { falloff: number; radius: number; shape: string; strength: number } + terrainSampling: boolean + terrainVerb: string + wandPanelScale: number + wallSnappingMode: string + scope: string + selectedIds: string[] + siteHasTerrain: boolean + tool: string | null + toolDefaults: Record + } + version: 1 +} + +declare global { + var __pascalXRLastGridEvent: string | undefined + var __pascalXRLastNodeEvent: string | undefined + var __pascalXRTestHarness: XREmulatorTestHarness | undefined +} + +export function XREmulatorTestHarnessBridge() { + const scene = useThree((state) => state.scene) + const camera = useThree((state) => state.camera) + const origin = useXR((state) => state.origin) + const session = useXR((state) => state.session) + + useEffect(() => { + if (!(origin && session && process.env.NODE_ENV === 'development')) return + + const recordNodeClick = (event: NodeEvent) => { + globalThis.__pascalXRLastNodeEvent = `click:${event.node.id}` + } + const recordNodeDown = (event: NodeEvent) => { + globalThis.__pascalXRLastNodeEvent = `down:${event.node.id}` + } + const recordGridClick = (event: GridEvent) => { + globalThis.__pascalXRLastGridEvent = `click:${event.localPosition.join(',')}` + } + emitter.on('node:click', recordNodeClick) + emitter.on('node:pointerdown', recordNodeDown) + emitter.on('grid:click', recordGridClick) + + const waitForXRFrames = (count = 1) => + new Promise((resolve) => { + const timeout = window.setTimeout(resolve, XR_FRAME_TIMEOUT_MS) + const next = (remaining: number) => { + session.requestAnimationFrame(() => { + if (remaining === 1) { + window.clearTimeout(timeout) + resolve() + } else next(remaining - 1) + }) + } + next(count) + }) + + const prepareInput = async (inputKind: InputKind) => { + const device = getEmulatedXRDevice() + if (!device) return false + const deviceId = `${inputKind}-right` + const inputModeChanged = device.primaryInputMode !== inputKind + if (inputModeChanged) { + await device.remote.dispatch('set_input_mode', { mode: inputKind }) + } + await device.remote.dispatch('set_connected', { + connected: true, + device: `${inputKind}-left`, + }) + await device.remote.dispatch('set_connected', { connected: true, device: deviceId }) + const leftPosition = + inputKind === 'controller' ? { x: -0.25, y: 1.5, z: -0.4 } : { x: -0.15, y: 1.3, z: -0.4 } + await device.remote.dispatch('set_transform', { + device: `${inputKind}-left`, + orientation: { w: 1, x: 0, y: 0, z: 0 }, + position: leftPosition, + }) + await waitForXRFrames(inputModeChanged ? 2 : 1) + return true + } + + const setInputPose = async (target: Object3D, inputKind: InputKind, distance = 0.5) => { + const device = getEmulatedXRDevice() + if (!device) return false + const pose = resolveEmulatedInputPose(target, origin, distance) + const deviceId = `${inputKind}-right` + await device.remote.dispatch('set_transform', { + device: deviceId, + orientation: { + w: pose.quaternion[3], + x: pose.quaternion[0], + y: pose.quaternion[1], + z: pose.quaternion[2], + }, + position: { x: pose.position[0], y: pose.position[1], z: pose.position[2] }, + }) + await waitForXRFrames() + return true + } + + const findTarget = (name: string) => { + const matches: Object3D[] = [] + scene.traverseVisible((object) => { + if (object.name === name) matches.push(object) + }) + return matches[0] + } + + const waitForTarget = async (name: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const target = findTarget(name) + if (target) return target + await waitForXRFrames() + } + return undefined + } + + const aimAt = async (name: string, inputKind: InputKind = 'controller') => { + globalThis.__pascalXRHoveredTarget = undefined + if (!(await prepareInput(inputKind))) return false + const target = await waitForTarget(name) + if (!(target && (await setInputPose(target, inputKind)))) return false + if (inputKind === 'hand') return true + for (let attempt = 0; attempt < 5; attempt += 1) { + if (globalThis.__pascalXRHoveredTarget === name) return true + await waitForXRFrames() + } + return findTarget(name) === target + } + + const aimAtNode = async ( + nodeId: string, + inputKind: InputKind = 'controller', + distance = 1.25, + ) => { + if (!(await prepareInput(inputKind))) return false + const device = getEmulatedXRDevice() + if (!device) return false + await device.remote.dispatch('set_transform', { + device: `${inputKind}-left`, + orientation: { w: 1, x: 0, y: 0, z: 0 }, + position: { x: -3, y: 1.5, z: 0 }, + }) + await waitForXRFrames() + const registered = sceneRegistry.nodes.get(nodeId) + if (!registered) return false + registered.updateWorldMatrix(true, true) + const bounds = new Box3().setFromObject(registered) + const target = new Object3D() + let targetDistance = distance + if (bounds.isEmpty()) { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + const vertices = ( + node as { topology?: { vertices?: { position?: number[] }[] } } | undefined + )?.topology?.vertices + const positions = vertices + ?.map((vertex) => vertex.position) + .filter( + (position): position is [number, number, number] => + position?.length === 3 && position.every(Number.isFinite), + ) + if (positions && positions.length > 0) { + const localBounds = new Box3().setFromPoints( + positions.map((position) => new Vector3().fromArray(position)), + ) + localBounds.getCenter(target.position) + registered.localToWorld(target.position) + const worldScale = registered.getWorldScale(new Vector3()) + targetDistance = Math.max( + targetDistance, + localBounds.getSize(new Vector3()).multiply(worldScale).length() / 2 + 0.25, + ) + } else { + registered.getWorldPosition(target.position) + } + } else { + bounds.getCenter(target.position) + targetDistance = Math.max(targetDistance, bounds.getSize(new Vector3()).length() / 2 + 0.25) + } + const normal = camera.getWorldPosition(new Vector3()).sub(target.position).normalize() + target.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), normal) + target.updateMatrixWorld(true) + const positioned = await setInputPose(target, inputKind, targetDistance) + if (positioned) await waitForXRFrames(2) + return positioned + } + + const setSelectValue = async (value: number, inputKind: InputKind) => { + const device = getEmulatedXRDevice() + if (!device) return false + await device.remote.dispatch('set_select_value', { + device: `${inputKind}-right`, + value, + }) + return true + } + + const waitForInputEvent = (inputKind: InputKind, eventType: 'selectend' | 'selectstart') => + new Promise((resolve) => { + const timeout = window.setTimeout(() => { + session.removeEventListener(eventType, listener) + resolve(false) + }, XR_INPUT_EVENT_TIMEOUT_MS) + const listener = (event: XRInputSourceEvent) => { + const matchesKind = + inputKind === 'hand' ? event.inputSource.hand != null : !event.inputSource.hand + if (event.inputSource.handedness !== 'right' || !matchesKind) return + window.clearTimeout(timeout) + session.removeEventListener(eventType, listener) + resolve(true) + } + session.addEventListener(eventType, listener) + }) + + const setSelectValueAndWait = async ( + value: 0 | 1, + inputKind: InputKind, + eventType: 'selectend' | 'selectstart', + ) => { + const eventReceived = waitForInputEvent(inputKind, eventType) + if (!(await setSelectValue(value, inputKind))) return false + await waitForXRFrames(2) + await eventReceived + return eventReceived + } + + const click = async (name: string, inputKind: InputKind = 'controller') => { + if (!(await aimAt(name, inputKind))) return false + for (let attempt = 0; attempt < 3; attempt += 1) { + if (attempt > 0 && !(await aimAt(name, inputKind))) return false + globalThis.__pascalXRLastPointerEvent = undefined + await setSelectValueAndWait(1, inputKind, 'selectstart') + await setSelectValueAndWait(0, inputKind, 'selectend') + if (globalThis.__pascalXRLastPointerEvent === `click:${name}`) return true + } + return false + } + + const panGodView = async (delta: [number, number, number]) => { + if (!(await prepareInput('controller'))) return false + const device = getEmulatedXRDevice() + const root = findTarget('xr-player-scene-root') + if (!(device && root)) return false + const deviceId = 'controller-right' + const transform = (await device.remote.dispatch('get_transform', { device: deviceId })) as { + orientation: { w: number; x: number; y: number; z: number } + position: { x: number; y: number; z: number } + } + await device.remote.dispatch('set_gamepad_state', { + buttons: [{ index: 1, value: 1 }], + device: deviceId, + }) + await waitForXRFrames(2) + await device.remote.dispatch('set_transform', { + device: deviceId, + orientation: transform.orientation, + position: { + x: transform.position.x + delta[0], + y: transform.position.y + delta[1], + z: transform.position.z + delta[2], + }, + }) + await waitForXRFrames(2) + await device.remote.dispatch('set_gamepad_state', { + buttons: [{ index: 1, value: 0 }], + device: deviceId, + }) + await waitForXRFrames() + return root.position.lengthSq() > 0.000_001 + } + + const clickLevelPoint = async ( + point: [number, number], + inputKind: InputKind = 'controller', + ) => { + if (!(await prepareInput(inputKind))) return false + const levelId = useViewer.getState().selection.levelId + const levelNode = levelId ? useScene.getState().nodes[levelId] : undefined + const levelObject = levelId ? sceneRegistry.nodes.get(levelId) : undefined + const device = getEmulatedXRDevice() + if (levelNode?.type !== 'level' || !device) return false + await device.remote.dispatch('set_transform', { + device: `${inputKind}-left`, + orientation: { w: 1, x: 0, y: 0, z: 0 }, + position: { x: -3, y: 1.5, z: 0 }, + }) + const buildingObject = levelNode.parentId + ? sceneRegistry.nodes.get(levelNode.parentId) + : undefined + levelObject?.updateWorldMatrix(true, false) + buildingObject?.updateWorldMatrix(true, false) + const target = new Object3D() + const localPoint = new Vector3(point[0], levelNode.baseElevation, point[1]) + target.position.copy( + levelObject + ? levelObject.localToWorld(new Vector3(point[0], 0, point[1])) + : buildingObject + ? buildingObject.localToWorld(localPoint) + : localPoint, + ) + const normal = new Vector3(0, 1, 0) + if (levelObject) normal.transformDirection(levelObject.matrixWorld) + else if (buildingObject) normal.transformDirection(buildingObject.matrixWorld) + target.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), normal) + target.updateMatrixWorld(true) + if (!(await setInputPose(target, inputKind, 1.25))) return false + await waitForXRFrames(2) + globalThis.__pascalXRLastGridEvent = undefined + await setSelectValueAndWait(1, inputKind, 'selectstart') + await setSelectValueAndWait(0, inputKind, 'selectend') + await waitForXRFrames(2) + const lastGridEvent = globalThis.__pascalXRLastGridEvent as string | undefined + return lastGridEvent?.startsWith('click:') === true + } + + const clickNode = async (nodeId: string, inputKind: InputKind = 'controller') => { + if (!(await aimAtNode(nodeId, inputKind))) return false + for (let attempt = 0; attempt < 3; attempt += 1) { + if (attempt > 0 && !(await aimAtNode(nodeId, inputKind))) return false + await setSelectValueAndWait(1, inputKind, 'selectstart') + await setSelectValueAndWait(0, inputKind, 'selectend') + if (useViewer.getState().selection.selectedIds.includes(nodeId)) return true + } + return false + } + + const sculptLevelPoints = async ( + points: [number, number][], + inputKind: InputKind = 'controller', + ) => { + const first = points[0] + if (!(first && (await prepareInput(inputKind)))) return false + const levelId = useViewer.getState().selection.levelId + const levelNode = levelId ? useScene.getState().nodes[levelId] : undefined + const device = getEmulatedXRDevice() + if (levelNode?.type !== 'level' || !device) return false + const buildingObject = levelNode.parentId + ? sceneRegistry.nodes.get(levelNode.parentId) + : undefined + buildingObject?.updateWorldMatrix(true, false) + const setPoint = async (point: [number, number]) => { + const target = new Object3D() + const localPoint = new Vector3(point[0], levelNode.baseElevation, point[1]) + target.position.copy(buildingObject ? buildingObject.localToWorld(localPoint) : localPoint) + const normal = new Vector3(0, 1, 0) + if (buildingObject) normal.transformDirection(buildingObject.matrixWorld) + target.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), normal) + target.updateMatrixWorld(true) + return setInputPose(target, inputKind, 1.25) + } + const siteId = useScene.getState().rootNodeIds[0] + const beforeSite = siteId ? useScene.getState().nodes[siteId] : undefined + const before = beforeSite?.type === 'site' ? beforeSite.terrain : undefined + if (!(await setPoint(first))) return false + if (!(await setSelectValueAndWait(1, inputKind, 'selectstart'))) return false + for (const point of points.slice(1)) { + if (!(await setPoint(point))) { + await setSelectValue(0, inputKind) + return false + } + await waitForXRFrames(2) + } + if (!(await setSelectValueAndWait(0, inputKind, 'selectend'))) return false + await waitForXRFrames(2) + const afterSite = siteId ? useScene.getState().nodes[siteId] : undefined + const after = afterSite?.type === 'site' ? afterSite.terrain : undefined + return JSON.stringify(after) !== JSON.stringify(before) + } + + const clickNodeSurface = async (nodeId: string, inputKind: InputKind = 'controller') => { + const aimAtNodeFace = async () => { + if (!(await prepareInput(inputKind))) return false + const registered = sceneRegistry.nodes.get(nodeId) + const device = getEmulatedXRDevice() + if (!(registered && device)) return false + await device.remote.dispatch('set_transform', { + device: `${inputKind}-left`, + orientation: { w: 1, x: 0, y: 0, z: 0 }, + position: { x: -3, y: 1.5, z: 0 }, + }) + registered.updateWorldMatrix(true, true) + const target = new Object3D() + const bounds = new Box3().setFromObject(registered) + if (bounds.isEmpty()) registered.getWorldPosition(target.position) + else bounds.getCenter(target.position) + registered.getWorldQuaternion(target.quaternion) + target.updateMatrixWorld(true) + const positioned = await setInputPose(target, inputKind, 0.2) + if (positioned) await waitForXRFrames(2) + return positioned + } + + if (!(await aimAtNodeFace())) return false + for (let attempt = 0; attempt < 3; attempt += 1) { + if (attempt > 0 && !(await aimAtNodeFace())) return false + globalThis.__pascalXRLastNodeEvent = undefined + await setSelectValueAndWait(1, inputKind, 'selectstart') + await setSelectValueAndWait(0, inputKind, 'selectend') + await waitForXRFrames(2) + if (globalThis.__pascalXRLastNodeEvent === `click:${nodeId}`) return true + } + return false + } + + const drag = async (names: string[], inputKind: InputKind = 'controller') => { + const first = names[0] + if (!(first && (await aimAt(first, inputKind)))) return false + await setSelectValueAndWait(1, inputKind, 'selectstart') + await waitForXRFrames(2) + for (const name of names.slice(1)) { + if (!(await aimAt(name, inputKind))) { + await setSelectValue(0, inputKind) + return false + } + } + await setSelectValueAndWait(0, inputKind, 'selectend') + await waitForXRFrames() + return globalThis.__pascalXRLastPointerEvent === `click:${names.at(-1)}` + } + + const dragNodeTo = async ( + nodeId: string, + worldPoint: [number, number, number], + inputKind: InputKind = 'controller', + ) => { + const registered = sceneRegistry.nodes.get(nodeId) + if (!registered) return false + if (!useViewer.getState().selection.selectedIds.includes(nodeId)) { + if (!(await clickNode(nodeId, inputKind))) return false + } + const initialNodeState = JSON.stringify(useScene.getState().nodes[nodeId as AnyNodeId]) + if (!(await aimAtNode(nodeId, inputKind))) return false + await setSelectValueAndWait(1, inputKind, 'selectstart') + await waitForXRFrames(2) + const floorTarget = new Object3D() + floorTarget.position.fromArray(worldPoint) + floorTarget.rotation.x = -Math.PI / 2 + floorTarget.updateMatrixWorld(true) + await setInputPose(floorTarget, inputKind, 1.25) + await waitForXRFrames(2) + await setSelectValueAndWait(0, inputKind, 'selectend') + await waitForXRFrames() + return JSON.stringify(useScene.getState().nodes[nodeId as AnyNodeId]) !== initialNodeState + } + + const probe = async (name: string, inputKind: InputKind = 'controller') => { + const device = getEmulatedXRDevice() + if (!device) return { error: 'missing device' } + await aimAt(name, inputKind) + const target = findTarget(name) + if (!target) return { error: 'missing target' } + const transform = (await device.remote.dispatch('get_transform', { + device: `${inputKind}-right`, + })) as { + orientation: { w: number; x: number; y: number; z: number } + position: { x: number; y: number; z: number } + } + const rayOrigin = new Vector3( + transform.position.x, + transform.position.y, + transform.position.z, + ).applyMatrix4(origin.matrixWorld) + const rayDirection = new Vector3(0, 0, -1) + .applyQuaternion( + new Quaternion( + transform.orientation.x, + transform.orientation.y, + transform.orientation.z, + transform.orientation.w, + ), + ) + .transformDirection(origin.matrixWorld) + const raycaster = new Raycaster(rayOrigin, rayDirection) + raycaster.layers.enableAll() + return { + rayDirection: rayDirection.toArray(), + rayOrigin: rayOrigin.toArray(), + targetPosition: target.getWorldPosition(new Vector3()).toArray(), + firstHits: raycaster + .intersectObjects(scene.children, true) + .slice(0, 8) + .map((hit) => ({ distance: hit.distance, name: hit.object.name })), + targetHits: raycaster.intersectObject(target, false).length, + } + } + + const probeNode = async ( + nodeId: string, + inputKind: InputKind = 'controller', + distance = 1.25, + ) => { + const registered = sceneRegistry.nodes.get(nodeId) + const device = getEmulatedXRDevice() + if (!(registered && device && (await aimAtNode(nodeId, inputKind, distance)))) { + return { error: 'missing node or device' } + } + const transform = (await device.remote.dispatch('get_transform', { + device: `${inputKind}-right`, + })) as { + orientation: { w: number; x: number; y: number; z: number } + position: { x: number; y: number; z: number } + } + const rayOrigin = new Vector3( + transform.position.x, + transform.position.y, + transform.position.z, + ).applyMatrix4(origin.matrixWorld) + const rayDirection = new Vector3(0, 0, -1) + .applyQuaternion( + new Quaternion( + transform.orientation.x, + transform.orientation.y, + transform.orientation.z, + transform.orientation.w, + ), + ) + .transformDirection(origin.matrixWorld) + const raycaster = new Raycaster(rayOrigin, rayDirection) + raycaster.layers.enableAll() + registered.updateWorldMatrix(true, true) + const registeredBounds = new Box3().setFromObject(registered) + const describeHit = (object: Object3D) => { + const path: { childTargets: string[]; eventCount: number; name: string; type: string }[] = + [] + let current: Object3D | null = object + while (current && path.length < 8) { + path.push({ + childTargets: current.children + .filter( + (child) => + ((child as Object3D & { __r3f?: { eventCount?: number } }).__r3f?.eventCount ?? + 0) > 0, + ) + .map((child) => child.name || child.type), + eventCount: + (current as Object3D & { __r3f?: { eventCount?: number } }).__r3f?.eventCount ?? 0, + name: current.name, + type: current.type, + }) + current = current.parent + } + return path + } + return { + bounds: registeredBounds.isEmpty() + ? null + : { + max: registeredBounds.max.toArray(), + min: registeredBounds.min.toArray(), + }, + childCount: registered.children.length, + firstHits: raycaster + .intersectObjects(scene.children, true) + .slice(0, 8) + .map((hit) => ({ + distance: hit.distance, + name: hit.object.name, + path: describeHit(hit.object), + })), + nodeHits: raycaster.intersectObject(registered, true).length, + rayDirection: rayDirection.toArray(), + rayOrigin: rayOrigin.toArray(), + registeredPosition: registered.getWorldPosition(new Vector3()).toArray(), + } + } + + const harness: XREmulatorTestHarness = { + aimAt, + aimAtNode, + click, + clickLevelPoint, + clickNode, + clickNodeSurface, + drag, + dragNodeTo, + listSceneNodes: () => + Object.values(useScene.getState().nodes) + .filter((node): node is NonNullable => node != null) + .map((node) => ({ id: node.id, parentId: node.parentId, type: node.type })) + .sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id)), + listSpatialTargets: () => { + const names = new Set() + scene.traverseVisible((object) => { + if (object.name.startsWith('xr-') && 'raycast' in object) names.add(object.name) + }) + return [...names].sort() + }, + placeToolOnGrid: async (toolTarget, nodeType, points, inputKind = 'controller') => { + const before = new Set( + Object.values(useScene.getState().nodes) + .filter((node) => node?.type === nodeType) + .map((node) => node!.id), + ) + const activated = await click(toolTarget, inputKind) + let deliveredPoints = 0 + if (activated) { + await waitForXRFrames(2) + for (const point of points) { + if (!(await clickLevelPoint(point, inputKind))) break + deliveredPoints += 1 + } + } + const createdNodeIds = Object.values(useScene.getState().nodes) + .filter((node): node is AnyNode => node?.type === nodeType && !before.has(node.id)) + .map((node) => node.id) + const cancelled = await click('xr-build-tool-select', inputKind) + return { activated, cancelled, createdNodeIds, deliveredPoints } + }, + placeToolOnNode: async (toolTarget, hostNodeId, nodeType, inputKind = 'controller') => { + const before = new Set( + Object.values(useScene.getState().nodes) + .filter((node) => node?.type === nodeType) + .map((node) => node!.id), + ) + const activated = await click(toolTarget, inputKind) + if (activated) await waitForXRFrames(2) + const deliveredHostClick = activated && (await clickNodeSurface(hostNodeId, inputKind)) + const createdNodes = Object.values(useScene.getState().nodes).filter( + (node): node is AnyNode => node?.type === nodeType && !before.has(node.id), + ) + const attachedToHost = + createdNodes.length > 0 && createdNodes.every((node) => node.parentId === hostNodeId) + const alreadySelect = + useEditor.getState().mode === 'select' && useEditor.getState().tool === null + const cancelled = alreadySelect || (await click('xr-build-tool-select', inputKind)) + return { + activated, + attachedToHost, + cancelled, + createdNodeIds: createdNodes.map((node) => node.id), + deliveredHostClick, + } + }, + panGodView, + probe, + probeNode, + readNode: (nodeId) => useScene.getState().nodes[nodeId as AnyNode['id']], + sculptLevelPoints, + snapshot: () => { + const godViewRoot = findTarget('xr-player-scene-root') + const nodeCounts: Record = {} + for (const node of Object.values(useScene.getState().nodes)) { + if (node) nodeCounts[node.type] = (nodeCounts[node.type] ?? 0) + 1 + } + return { + activePaintMaterial: useEditor.getState().activePaintMaterial?.materialPreset ?? null, + godViewTransform: godViewRoot + ? { + position: godViewRoot.position.toArray(), + rotationY: godViewRoot.rotation.y, + scale: godViewRoot.scale.toArray(), + } + : null, + history: getHistoryCommandState(), + hoveredTarget: globalThis.__pascalXRHoveredTarget, + lastGridEvent: globalThis.__pascalXRLastGridEvent, + lastNodeEvent: globalThis.__pascalXRLastNodeEvent, + lastPointerEvent: globalThis.__pascalXRLastPointerEvent, + levelId: useViewer.getState().selection.levelId, + mode: useEditor.getState().mode, + nodeCounts, + paintEraser: useEditor.getState().paintEraser, + paintHover: useEditor.getState().paintHover, + paintScope: useEditor.getState().paintScope, + terrainBrush: useEditor.getState().terrainBrush, + terrainSampling: useEditor.getState().terrainSampling, + terrainVerb: useEditor.getState().terrainVerb, + wandPanelScale: useXRWandPanelSettings.getState().panelScale, + wallSnappingMode: useEditor.getState().snappingModeByContext.wall, + scope: useInteractionScope.getState().scope.kind, + selectedIds: useViewer.getState().selection.selectedIds, + siteHasTerrain: Object.values(useScene.getState().nodes).some( + (node) => node?.type === 'site' && node.terrain !== undefined, + ), + tool: useEditor.getState().tool, + toolDefaults: useEditor.getState().toolDefaults, + } + }, + version: 1, + } + globalThis.__pascalXRTestHarness = harness + return () => { + emitter.off('node:click', recordNodeClick) + emitter.off('node:pointerdown', recordNodeDown) + emitter.off('grid:click', recordGridClick) + if (globalThis.__pascalXRTestHarness === harness) { + globalThis.__pascalXRTestHarness = undefined + } + } + }, [camera, origin, scene, session]) + + return null +} diff --git a/apps/editor/components/xr/xr-preview-environment.tsx b/apps/editor/components/xr/xr-preview-environment.tsx new file mode 100644 index 0000000000..4cfcbf4838 --- /dev/null +++ b/apps/editor/components/xr/xr-preview-environment.tsx @@ -0,0 +1,329 @@ +'use client' + +import { initSpaceDetectionSync, SiteNode, useScene } from '@pascal-app/core' +import { + applySceneGraphToEditor, + Grid, + NodeArrowHandles, + type SceneGraph, + SelectionManager, + selectDefaultBuildingAndLevel, + ToolManager, + useEditor, + WallMoveSideHandles, +} from '@pascal-app/editor' +import { + requestGodScaleReset, + toggleXRPlayerMode, + useViewer, + useXRPlayerMode, + Viewer, + XR_PLAYER_MODES, +} from '@pascal-app/viewer' +import { Glasses, LoaderCircle, Orbit, PersonStanding, RotateCcw, X } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { mountEmulatorControls } from '@/lib/xr/emulator' +import { XR_PREVIEW_SCENE_KEY } from '@/lib/xr/preview-window' +import { XRWandInputOverlay } from './wand-panel' +import { XREditorInputBridge } from './xr-editor-input-bridge' +import { XREmulatorTestHarnessBridge } from './xr-emulator-test-harness' +import { XRRenderErrorBoundary } from './xr-render-error-boundary' +import { requestEditorVRSession, useEditorXRRuntime, xrConfigForRuntime } from './xr-runtime' + +const LOCAL_SCENE_KEY = 'pascal-editor-scene' + +function endXRSession(session?: XRSession) { + if (!session) return + void session.end().catch(() => undefined) +} + +type PreviewScene = { + graph: SceneGraph + name: string +} + +function ensureXRPreviewSite(graph: SceneGraph): SceneGraph { + const rootNodeIds = graph.rootNodeIds ?? [] + const sourceNodes = graph.nodes as Record + const existingSite = rootNodeIds.some((id) => sourceNodes[id]?.type === 'site') + if (existingSite) return graph + + const buildingIds = Object.values(sourceNodes) + .filter((node) => node?.type === 'building') + .map((node) => node.id) + if (buildingIds.length === 0) return graph + + const site = SiteNode.parse({ + id: 'site_xr_preview' as never, + type: 'site', + name: 'XR Preview Site', + polygon: { + type: 'polygon', + points: [ + [-100, -100], + [100, -100], + [100, 100], + [-100, 100], + ], + }, + children: buildingIds, + }) + const nodes = Object.fromEntries( + Object.entries(sourceNodes).map(([id, node]) => + node?.type === 'building' ? [id, { ...node, parentId: site.id }] : [id, node], + ), + ) + return { + ...graph, + nodes: { ...nodes, [site.id]: site }, + rootNodeIds: [site.id], + } +} + +function XREditorScene() { + const gridSnapStep = useEditor((state) => state.gridSnapStep) + + return ( + <> + + + + + + + + + ) +} + +export function XRPreviewEnvironment({ + liveSnapshot = false, + sceneId, +}: { + liveSnapshot?: boolean + sceneId?: string +}) { + const runtime = useEditorXRRuntime(true) + const [scene, setScene] = useState() + const [session, setSession] = useState() + const [error, setError] = useState(null) + const [editorReady, setEditorReady] = useState(false) + const [inputSummary, setInputSummary] = useState('No tracked inputs') + const [enteringVR, setEnteringVR] = useState(false) + const sessionRequest = useRef | null>(null) + const playerMode = useXRPlayerMode((state) => state.mode) + const selectedIds = useViewer((state) => state.selection.selectedIds) + + useEffect(() => { + const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor) + return () => unsubscribeSpaceDetection() + }, []) + + useEffect(() => { + let cancelled = false + void Promise.resolve(useEditor.persist.rehydrate()).then(() => { + if (!cancelled) setEditorReady(true) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + let cancelled = false + + if (liveSnapshot || !sceneId) { + try { + const storageKey = liveSnapshot ? XR_PREVIEW_SCENE_KEY : LOCAL_SCENE_KEY + const graph = JSON.parse(localStorage.getItem(storageKey) ?? 'null') as SceneGraph | null + setScene( + graph ? { graph, name: liveSnapshot ? 'Current editor scene' : 'Local scene' } : null, + ) + } catch { + setScene(null) + } + return + } + + fetch(`/api/scenes/${encodeURIComponent(sceneId)}`, { cache: 'no-store' }) + .then(async (response) => { + if (!response.ok) throw new Error(`Could not load scene (${response.status})`) + return (await response.json()) as PreviewScene + }) + .then((nextScene) => { + if (!cancelled) setScene(nextScene) + }) + .catch((loadError: unknown) => { + if (cancelled) return + setError(loadError instanceof Error ? loadError.message : 'Could not load scene') + setScene(null) + }) + + return () => { + cancelled = true + } + }, [liveSnapshot, sceneId]) + + useEffect(() => { + if (!(editorReady && scene)) return + applySceneGraphToEditor(ensureXRPreviewSite(scene.graph)) + selectDefaultBuildingAndLevel() + useEditor.setState({ mode: 'select', tool: null }) + return () => applySceneGraphToEditor(null) + }, [editorReady, scene]) + + useEffect(() => { + if (runtime.status !== 'ready') return + + const updateInputSummary = () => { + const state = runtime.store.getState() + const inputs = state.inputSourceStates + setInputSummary( + inputs.length === 0 + ? state.session + ? `Session connected · ${state.session.inputSources.length} source(s) · no tracked inputs` + : 'XR store is waiting for the session' + : inputs.map((input) => `${input.inputSource.handedness} ${input.type}`).join(' · '), + ) + } + updateInputSummary() + return runtime.store.subscribe(updateInputSummary) + }, [runtime]) + + useEffect(() => { + if (!(session && runtime.status === 'ready' && runtime.source === 'emulated')) return + return mountEmulatorControls() + }, [runtime, session]) + + useEffect(() => { + if (!session) return + useEditor.setState({ mode: 'select', tool: null }) + }, [session]) + + const enterVR = useCallback(async () => { + if (runtime.status !== 'ready' || session || sessionRequest.current) return + setError(null) + setEnteringVR(true) + + const request = (async () => { + try { + const nextSession = await requestEditorVRSession(runtime.store) + nextSession.addEventListener('end', () => setSession(undefined), { once: true }) + setSession(nextSession) + } catch (sessionError) { + setError(sessionError instanceof Error ? sessionError.message : 'Could not enter VR') + } finally { + setEnteringVR(false) + sessionRequest.current = null + } + })() + + sessionRequest.current = request + await request + }, [runtime, session]) + + const xr = session + ? { ...xrConfigForRuntime(runtime, session)!, inputSourceOverlay: XRWandInputOverlay } + : undefined + + if (xr && scene) { + return ( +
+ endXRSession(session)}> + + + + +
+ {playerMode === XR_PLAYER_MODES.GOD ? 'God mode' : 'Human mode'} · {inputSummary} +
+
+ + {playerMode === XR_PLAYER_MODES.GOD && ( + + )} + +
+
+ ) + } + + const preparing = + !editorReady || runtime.status === 'idle' || runtime.status === 'loading' || scene === undefined + const unavailable = + runtime.status === 'unsupported' || runtime.status === 'error' || scene === null + + return ( +
+
+ +

WebXR test environment

+

+ {scene?.name ?? 'Preparing the scene'} opens here independently from the editor. Start the + immersive session when the runtime is ready. +

+ {error &&

{error}

} + {runtime.status === 'error' && ( +

{runtime.message}

+ )} + {scene === null && !error && ( +

No local scene is available to preview.

+ )} +
+ + +
+
+
+ ) +} diff --git a/apps/editor/components/xr/xr-render-error-boundary.tsx b/apps/editor/components/xr/xr-render-error-boundary.tsx new file mode 100644 index 0000000000..212d3f735a --- /dev/null +++ b/apps/editor/components/xr/xr-render-error-boundary.tsx @@ -0,0 +1,48 @@ +'use client' + +import type { ErrorInfo, ReactNode } from 'react' +import { Component } from 'react' + +type Props = { + children: ReactNode + onExit: () => void +} + +type State = { + error: Error | null +} + +export class XRRenderErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[editor/xr] Immersive render failed', error, info.componentStack) + } + + render() { + if (!this.state.error) return this.props.children + + return ( +
+
+

XR render error

+

The immersive scene could not render

+

+ {this.state.error.message || 'An unknown WebXR rendering error occurred.'} +

+ +
+
+ ) + } +} diff --git a/apps/editor/components/xr/xr-runtime.tsx b/apps/editor/components/xr/xr-runtime.tsx new file mode 100644 index 0000000000..9b50c78985 --- /dev/null +++ b/apps/editor/components/xr/xr-runtime.tsx @@ -0,0 +1,79 @@ +'use client' + +import { createViewerXRStore, type ViewerXRConfig, type ViewerXRStore } from '@pascal-app/viewer' +import { useEffect, useState } from 'react' +import { prepareXRPlatform, type XRRuntimeSource } from '@/lib/xr/emulator' + +type XRRuntimeState = + | { status: 'idle' | 'loading' } + | { message: string; status: 'error' } + | { source: XRRuntimeSource; status: 'ready'; store: ViewerXRStore } + | { status: 'unsupported' } + +export function useEditorXRRuntime(enabled: boolean): XRRuntimeState { + const [runtime, setRuntime] = useState({ status: 'idle' }) + + useEffect(() => { + if (!enabled) return + + let cancelled = false + setRuntime({ status: 'loading' }) + prepareXRPlatform() + .then((source) => { + if (cancelled) return + if (source === 'unsupported') { + setRuntime({ status: 'unsupported' }) + return + } + setRuntime({ + source, + status: 'ready', + // The editor uses ordinary spatial meshes for its wand and scene; + // disabling Layers avoids an unnecessary WebGL emulator framebuffer. + store: createViewerXRStore({ layers: false }), + }) + }) + .catch((error: unknown) => { + if (cancelled) return + setRuntime({ + message: error instanceof Error ? error.message : 'Could not initialize WebXR', + status: 'error', + }) + }) + + return () => { + cancelled = true + } + }, [enabled]) + + return runtime +} + +export async function requestEditorVRSession(store: ViewerXRStore): Promise { + if (!navigator.xr) throw new Error('Immersive VR is unavailable') + + const domOverlayRoot = store.getState().domOverlayRoot + return navigator.xr.requestSession('immersive-vr', { + requiredFeatures: ['local-floor'], + optionalFeatures: [ + 'anchors', + 'dom-overlay', + 'hand-tracking', + 'hit-test', + 'mesh-detection', + 'plane-detection', + ], + ...(domOverlayRoot ? { domOverlay: { root: domOverlayRoot } } : {}), + }) +} + +export function xrConfigForRuntime( + runtime: XRRuntimeState, + session?: XRSession, +): ViewerXRConfig | undefined { + return runtime.status === 'ready' + ? { multiview: false, playerModes: true, session, store: runtime.store } + : undefined +} + +export type { XRRuntimeState } diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 6708f1305c..a97d8fc226 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -12,6 +12,7 @@ import { builtinPlugin } from '@pascal-app/nodes' import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones' import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape' import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' +import { webXRHostPanel, webXRPlugin } from '@pascal-local/plugin-webxr' // Idempotency guards: HMR can reload this module, but `registerNode` // throws on duplicate kinds. Flags live in the module closure so they @@ -100,6 +101,8 @@ registerEditorHostPanel({ ...streetscapeHostPanel, creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, }) +extendPluginDiscovery(async () => [webXRPlugin]) +registerEditorHostPanel(webXRHostPanel) loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/lib/build-palette.test.ts b/apps/editor/lib/build-palette.test.ts new file mode 100644 index 0000000000..19e5fd1204 --- /dev/null +++ b/apps/editor/lib/build-palette.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { emitter } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { + activateBuildTool, + activateSelectMode, + collectXRBuildPaletteManifest, + XR_MEP_ITEMS, +} from './build-palette' + +describe('build palette actions', () => { + afterEach(() => { + useEditor.getState().setMode('select') + }) + + test('cancels the active tool before returning to Select mode', () => { + const editor = useEditor.getState() + editor.setPhase('structure') + editor.setMode('build') + editor.setTool('door') + let modeWhenCancelled: string | null = null + const onCancel = () => { + modeWhenCancelled = useEditor.getState().mode + } + emitter.on('tool:cancel', onCancel) + + try { + activateSelectMode() + } finally { + emitter.off('tool:cancel', onCancel) + } + + expect(modeWhenCancelled).toBe('build') + expect(useEditor.getState().mode).toBe('select') + expect(useEditor.getState().tool).toBeNull() + }) + + test('exposes every XR submenu entry once with Select first', () => { + const manifest = collectXRBuildPaletteManifest('expert') + + for (const entries of Object.values(manifest)) { + expect(entries[0]).toBe('select') + expect(new Set(entries).size).toBe(entries.length) + } + expect(manifest.mep.slice(1)).toEqual(XR_MEP_ITEMS.map((entry) => entry.id)) + }) + + test('clears selection and exposes the chosen tool defaults', () => { + useEditor.getState().setToolDefaults('wall', { height: 9 }) + + activateBuildTool('wall') + + expect(useEditor.getState().mode).toBe('build') + expect(useEditor.getState().tool).toBe('wall') + expect(useEditor.getState().toolDefaults.wall).toBeUndefined() + }) +}) diff --git a/apps/editor/lib/build-palette.ts b/apps/editor/lib/build-palette.ts new file mode 100644 index 0000000000..b042c610af --- /dev/null +++ b/apps/editor/lib/build-palette.ts @@ -0,0 +1,253 @@ +import { emitter, nodeRegistry, type RoofType } from '@pascal-app/core' +import { + CATALOG_ITEMS, + type FloorplanMode, + getFloorplanNodeExtension, + isFloorplanToolAvailableInMode, + useEditor, + useFloorplanMode, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { + getRoofFootprintSource, + ROOF_TYPE_OPTIONS, + type RoofFootprintSource, +} from '@/lib/build-tab-state' + +export type MepToolKind = + | 'duct-segment' + | 'duct-fitting' + | 'duct-terminal' + | 'hvac-equipment' + | 'lineset' + | 'liquid-line' + | 'pipe-segment' + | 'pipe-fitting' + | 'pipe-trap' + +export type BuildType = { + id: string + label: string + iconSrc: string + kind?: string + paletteOrder?: number + mode?: 'material-paint' | 'terrain-sculpt' +} + +export type MepItem = { + id: string + label: string + iconSrc: string + kind: MepToolKind +} + +export type RoofFeature = { + id: string + label: string + iconSrc: string + kind?: string +} + +export const BASE_BUILD_TYPES: BuildType[] = [ + { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, + { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, + { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, + { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, + { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, + { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, + { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, + { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, + { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, + { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, + { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, + { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, + { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, + { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, + { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, +] + +export const MEP_ITEMS: MepItem[] = [ + { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, + { + id: 'duct-terminal', + label: 'Register', + iconSrc: '/icons/registers.webp', + kind: 'duct-terminal', + }, + { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, + { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, + { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, + { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, +] + +export const XR_MEP_ITEMS: MepItem[] = [ + ...MEP_ITEMS, + { + id: 'duct-fitting', + label: 'Duct Fitting', + iconSrc: '/icons/duct-fitting.webp', + kind: 'duct-fitting', + }, + { + id: 'pipe-fitting', + label: 'Pipe Fitting', + iconSrc: '/icons/duct-fitting.webp', + kind: 'pipe-fitting', + }, + { + id: 'pipe-trap', + label: 'Pipe Trap', + iconSrc: '/icons/dwv-pipes.webp', + kind: 'pipe-trap', + }, +] + +export const MEP_TOOL_KINDS = new Set([ + ...MEP_ITEMS.map((item) => item.kind), + 'duct-fitting', + 'pipe-fitting', + 'pipe-trap', +]) + +const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') +export const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' + +export function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { + const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) + const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ + ...type, + paletteOrder: + nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, + })) + for (const [kind, definition] of nodeRegistry.entries()) { + const presentation = definition.presentation + const extension = getFloorplanNodeExtension(definition) + if ( + baseKinds.has(kind) || + presentation?.paletteGroup === 'roof-features' || + !extension?.tool || + !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || + !presentation || + presentation.hidden || + presentation.paletteSection !== 'structure' + ) { + continue + } + tools.push({ + id: kind, + kind, + label: presentation.label, + iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', + paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, + }) + } + tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) + return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] +} + +export function collectXRBuildPaletteManifest(floorplanMode: FloorplanMode) { + return { + main: ['select', ...collectBuildTypes(floorplanMode).map((entry) => entry.id)], + mep: ['select', ...XR_MEP_ITEMS.map((entry) => entry.id)], + roof: [ + 'select', + ...ROOF_TYPE_OPTIONS.map((entry) => `roof-${entry.value}`), + ...collectRoofFeatures().map((entry) => entry.id), + ], + } +} + +export function activateBuildTool(kind: string): void { + const editor = useEditor.getState() + const definition = nodeRegistry.get(kind) + const extension = getFloorplanNodeExtension(definition) + if ( + !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) + ) { + useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) + return + } + if (extension?.preferredView) editor.setViewMode(extension.preferredView) + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setCatalogCategory(null) + editor.setToolDefaults(kind, null) + editor.setMode('build') + editor.setTool(kind) +} + +export function activateSelectMode(): void { + emitter.emit('tool:cancel') + useEditor.getState().setMode('select') +} + +export function activateModularCabinetTool(): void { + const editor = useEditor.getState() + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + if (MODULAR_CABINET_CATALOG_ITEM) editor.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setCatalogCategory(null) + editor.setMode('build') + editor.setTool('cabinet') +} + +export function activatePaintMode(): void { + const editor = useEditor.getState() + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setMode('material-paint') +} + +export function activateTerrainSculptMode(): void { + useEditor.getState().setMode('terrain-sculpt') +} + +export function collectRoofFeatures(): RoofFeature[] { + const features: RoofFeature[] = [] + for (const [kind, definition] of nodeRegistry.entries()) { + if ( + definition.capabilities.roofAccessory === undefined && + definition.presentation?.paletteGroup !== 'roof-features' + ) { + continue + } + if (definition.capabilities.wallOpeningPlacement) continue + const icon = definition.presentation?.icon + features.push({ + id: kind, + kind, + label: definition.presentation?.label ?? kind, + iconSrc: icon?.kind === 'url' ? icon.src : '/icons/roof.webp', + }) + } + return features +} + +export function activateRoofFeatureTool(feature: RoofFeature): void { + const editor = useEditor.getState() + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setCatalogCategory(null) + editor.setMode('build') + if (feature.kind) editor.setTool(feature.kind) +} + +export function activateRoofType(roofType: RoofType): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + const footprintSource = getRoofFootprintSource( + roofType, + editor.toolDefaults.roof?.footprintSource, + ) + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType, footprintSource }) +} + +export function activateRoofFootprintSource(footprintSource: RoofFootprintSource): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, footprintSource }) +} diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts index 478f4e559f..855867f188 100644 --- a/apps/editor/lib/build-tab-state.ts +++ b/apps/editor/lib/build-tab-state.ts @@ -5,6 +5,32 @@ export type RoofFeatureIdentity = { kind?: string } +const ROOF_FOOTPRINT_SOURCES = [ + { label: 'Room', value: 'room' }, + { label: 'Wall', value: 'walls' }, + { label: 'Draw', value: 'draw' }, +] as const + +export type RoofFootprintSource = (typeof ROOF_FOOTPRINT_SOURCES)[number]['value'] + +const CONICAL_ROOF_FOOTPRINT_SOURCES = [ROOF_FOOTPRINT_SOURCES[1]] as const + +const STANDARD_ROOF_FOOTPRINT_SOURCES = [ + ROOF_FOOTPRINT_SOURCES[2], + ROOF_FOOTPRINT_SOURCES[0], +] as const + +export function getRoofFootprintSources(roofType: RoofType) { + return roofType === 'conical' ? CONICAL_ROOF_FOOTPRINT_SOURCES : STANDARD_ROOF_FOOTPRINT_SOURCES +} + +export function getRoofFootprintSource(roofType: RoofType, value: unknown): RoofFootprintSource { + const sources = getRoofFootprintSources(roofType) + return sources.some((source) => source.value === value) + ? (value as RoofFootprintSource) + : sources[0].value +} + export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [ { label: 'Hip', value: 'hip' }, { label: 'Gable', value: 'gable' }, diff --git a/apps/editor/lib/xr/editor-input.test.ts b/apps/editor/lib/xr/editor-input.test.ts new file mode 100644 index 0000000000..7f739e22a8 --- /dev/null +++ b/apps/editor/lib/xr/editor-input.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' +import { + didXRButtonPressStart, + isXRCancelPressed, + pulseXRInputSource, + replayXRWallOpeningRelease, + resolveXRReleaseAction, + selectPrimaryXRInputSource, + shouldReleaseCapturedXRInput, + shouldRouteXRMove, + XRSelectReleaseGuard, +} from './editor-input' + +function inputSource({ + button5 = false, + handedness, + targetRayMode = 'tracked-pointer', +}: { + button5?: boolean + handedness: XRHandedness + targetRayMode?: XRTargetRayMode +}): XRInputSource { + return { + gamepad: { buttons: [{}, {}, {}, {}, {}, { pressed: button5 }] }, + handedness, + targetRayMode, + } as unknown as XRInputSource +} + +describe('XR editor input routing', () => { + test('defers empty-space deselection until node click listeners have run', async () => { + const right = inputSource({ handedness: 'right' }) + const guard = new XRSelectReleaseGuard() + let deselections = 0 + + guard.start(right) + guard.deferEmptyRelease(right, () => deselections++) + guard.markNodeClick(right) + await Promise.resolve() + expect(deselections).toBe(0) + + guard.start(right) + guard.deferEmptyRelease(right, () => deselections++) + await Promise.resolve() + expect(deselections).toBe(1) + }) + + test('cancels deferred deselection and keeps input-source cycles isolated', async () => { + const left = inputSource({ handedness: 'left' }) + const right = inputSource({ handedness: 'right' }) + const guard = new XRSelectReleaseGuard() + let deselections = 0 + + guard.start(right) + guard.deferEmptyRelease(right, () => deselections++) + guard.cancel(right) + await Promise.resolve() + expect(deselections).toBe(0) + + guard.start(right) + guard.deferEmptyRelease(right, () => deselections++) + guard.markNodeClick(left) + await Promise.resolve() + expect(deselections).toBe(1) + }) + + test('routes select, tool, and drag releases without cross-triggering deselection', () => { + expect( + resolveXRReleaseAction({ mode: 'select', placementDrag: false, scopeKind: 'idle' }), + ).toBe('defer-empty-selection') + expect( + resolveXRReleaseAction({ mode: 'select', placementDrag: false, scopeKind: 'handle-drag' }), + ).toBe('ignore') + expect(resolveXRReleaseAction({ mode: 'build', placementDrag: false, scopeKind: 'idle' })).toBe( + 'emit-tool-grid-click', + ) + expect( + resolveXRReleaseAction({ mode: 'material-paint', placementDrag: false, scopeKind: 'idle' }), + ).toBe('ignore') + expect(resolveXRReleaseAction({ mode: 'select', placementDrag: true, scopeKind: 'idle' })).toBe( + 'finish-placement-drag', + ) + }) + + test('restores a wall opening draft before committing an XR release', () => { + const wallEvent = { node: { id: 'wall_test' } } + const emitted: string[] = [] + let draftExists = false + let placements = 0 + + expect( + replayXRWallOpeningRelease(wallEvent, (suffix) => { + emitted.push(suffix) + if (suffix === 'move') draftExists = true + if (suffix === 'click' && draftExists) placements += 1 + }), + ).toBe(true) + expect(emitted).toEqual(['move', 'click']) + expect(placements).toBe(1) + }) + + test('keeps the input source that owns the active press', () => { + const left = inputSource({ handedness: 'left' }) + const right = inputSource({ handedness: 'right' }) + expect(selectPrimaryXRInputSource([left, right], left)).toBe(left) + }) + + test('prefers the right tracked pointer while idle', () => { + const left = inputSource({ handedness: 'left' }) + const right = inputSource({ handedness: 'right' }) + expect(selectPrimaryXRInputSource([left, right])).toBe(right) + }) + + test('maps the right controller B button to cancel on its rising edge', () => { + const right = inputSource({ button5: true, handedness: 'right' }) + expect(isXRCancelPressed([right])).toBe(true) + expect(didXRButtonPressStart(false, true)).toBe(true) + expect(didXRButtonPressStart(true, true)).toBe(false) + }) + + test('keeps a captured drag moving even when it crosses the wand panel', () => { + const left = inputSource({ handedness: 'left' }) + expect(shouldRouteXRMove(left, left, true)).toBe(true) + expect(shouldRouteXRMove(left, null, true)).toBe(false) + expect(shouldRouteXRMove(left, null, false)).toBe(true) + }) + + test('releases a captured source after it disconnects', () => { + const left = inputSource({ handedness: 'left' }) + const right = inputSource({ handedness: 'right' }) + expect(shouldReleaseCapturedXRInput([left, right], left)).toBe(false) + expect(shouldReleaseCapturedXRInput([right], left)).toBe(true) + expect(shouldReleaseCapturedXRInput([right], null)).toBe(false) + }) + + test('pulses supported haptics and ignores unsupported input sources', async () => { + const pulse = async () => true + const supported = { + gamepad: { hapticActuators: [{ pulse }] }, + } as unknown as XRInputSource + const unsupported = { gamepad: { buttons: [] } } as unknown as XRInputSource + + expect(pulseXRInputSource(supported)).toBe(true) + expect(pulseXRInputSource(unsupported)).toBe(false) + }) +}) diff --git a/apps/editor/lib/xr/editor-input.ts b/apps/editor/lib/xr/editor-input.ts new file mode 100644 index 0000000000..e6e91529ca --- /dev/null +++ b/apps/editor/lib/xr/editor-input.ts @@ -0,0 +1,126 @@ +export class XRSelectReleaseGuard { + private readonly cycles = new WeakMap() + + start(source: XRInputSource) { + this.cycles.set(source, { nodeClicked: false }) + } + + markNodeClick(source: XRInputSource) { + const cycle = this.cycles.get(source) + if (cycle) cycle.nodeClicked = true + } + + cancel(source: XRInputSource) { + this.cycles.delete(source) + } + + deferEmptyRelease(source: XRInputSource, onEmptyRelease: () => void) { + const cycle = this.cycles.get(source) + if (!cycle) return + + queueMicrotask(() => { + if (this.cycles.get(source) !== cycle) return + this.cycles.delete(source) + if (!cycle.nodeClicked) onEmptyRelease() + }) + } +} + +export type XRReleaseAction = + | 'defer-empty-selection' + | 'emit-tool-grid-click' + | 'finish-placement-drag' + | 'ignore' + +export function replayXRWallOpeningRelease( + event: T | null, + emit: (suffix: 'move' | 'click', event: T) => void, +): boolean { + if (!event) return false + emit('move', event) + emit('click', event) + return true +} + +export function resolveXRReleaseAction({ + mode, + placementDrag, + scopeKind, +}: { + mode: string + placementDrag: boolean + scopeKind: string +}): XRReleaseAction { + if (placementDrag) return 'finish-placement-drag' + // Paint is committed by the shared node click handler, just like desktop + // paint. Do not also route an empty XR release through grid tool logic. + if (mode === 'material-paint') return 'ignore' + if (mode !== 'select') return 'emit-tool-grid-click' + return scopeKind === 'idle' ? 'defer-empty-selection' : 'ignore' +} + +export function selectPrimaryXRInputSource( + inputSources: readonly XRInputSource[], + activeInputSource?: XRInputSource | null, +): XRInputSource | null { + if (activeInputSource && inputSources.includes(activeInputSource)) return activeInputSource + + return ( + inputSources.find( + (source) => source.handedness === 'right' && source.targetRayMode === 'tracked-pointer', + ) ?? + inputSources.find((source) => source.targetRayMode === 'tracked-pointer') ?? + null + ) +} + +export function shouldRouteXRMove( + source: XRInputSource | null, + capturedSource: XRInputSource | null, + panelHit: boolean, +): boolean { + if (!source) return false + return source === capturedSource || !panelHit +} + +export function shouldReleaseCapturedXRInput( + inputSources: readonly XRInputSource[], + capturedSource: XRInputSource | null, +): boolean { + return capturedSource != null && !inputSources.includes(capturedSource) +} + +export function isXRCancelPressed(inputSources: readonly XRInputSource[]): boolean { + const rightController = inputSources.find( + (source) => source.handedness === 'right' && source.gamepad != null, + ) + return rightController?.gamepad?.buttons[5]?.pressed === true +} + +export function didXRButtonPressStart(previousPressed: boolean, nextPressed: boolean): boolean { + return !previousPressed && nextPressed +} + +export function pulseXRInputSource( + source: XRInputSource, + intensity = 0.18, + durationMs = 25, +): boolean { + const actuator = ( + source.gamepad as + | (Gamepad & { + hapticActuators?: readonly { + pulse: (intensity: number, duration: number) => Promise + }[] + }) + | null + )?.hapticActuators?.[0] + if (!actuator) return false + + try { + void actuator.pulse(intensity, durationMs).catch(() => undefined) + return true + } catch { + return false + } +} diff --git a/apps/editor/lib/xr/emulator-ray.test.ts b/apps/editor/lib/xr/emulator-ray.test.ts new file mode 100644 index 0000000000..5266d4cd31 --- /dev/null +++ b/apps/editor/lib/xr/emulator-ray.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { Group, Vector3 } from 'three' +import { resolveEmulatedInputPose } from './emulator-ray' + +describe('emulated XR input pose', () => { + test('places the ray in front of a target in reference-space coordinates', () => { + const origin = new Group() + origin.position.set(10, 0, 0) + const target = new Group() + target.position.set(10, 1, -2) + + const pose = resolveEmulatedInputPose(target, origin, 0.5) + const direction = new Vector3(0, 0, -1).applyQuaternion({ + x: pose.quaternion[0], + y: pose.quaternion[1], + z: pose.quaternion[2], + w: pose.quaternion[3], + }) + + expect(pose.position).toEqual([0, 1, -1.5]) + expect(direction.toArray()).toEqual([0, 0, -1]) + }) +}) diff --git a/apps/editor/lib/xr/emulator-ray.ts b/apps/editor/lib/xr/emulator-ray.ts new file mode 100644 index 0000000000..0d29e02cd4 --- /dev/null +++ b/apps/editor/lib/xr/emulator-ray.ts @@ -0,0 +1,29 @@ +import { Matrix4, type Object3D, Quaternion, Vector3 } from 'three' + +export type EmulatedInputPose = { + position: [number, number, number] + quaternion: [number, number, number, number] +} + +const FORWARD = new Vector3(0, 0, -1) + +export function resolveEmulatedInputPose( + target: Object3D, + referenceOrigin: Object3D, + distance = 0.5, +): EmulatedInputPose { + target.updateWorldMatrix(true, false) + referenceOrigin.updateWorldMatrix(true, false) + const targetPosition = target.getWorldPosition(new Vector3()) + const targetNormal = new Vector3(0, 0, 1).transformDirection(target.matrixWorld) + const inputPosition = targetPosition.clone().addScaledVector(targetNormal, distance) + const rayDirection = targetNormal.negate() + const worldToReference = new Matrix4().copy(referenceOrigin.matrixWorld).invert() + inputPosition.applyMatrix4(worldToReference) + rayDirection.transformDirection(worldToReference) + const quaternion = new Quaternion().setFromUnitVectors(FORWARD, rayDirection) + return { + position: inputPosition.toArray(), + quaternion: quaternion.toArray(), + } +} diff --git a/apps/editor/lib/xr/emulator.ts b/apps/editor/lib/xr/emulator.ts new file mode 100644 index 0000000000..3445f705e7 --- /dev/null +++ b/apps/editor/lib/xr/emulator.ts @@ -0,0 +1,74 @@ +'use client' + +import { getImmersiveVRSupport } from '@pascal-app/viewer' +import type { XRDevice } from 'iwer' + +export type XRRuntimeSource = 'native' | 'emulated' | 'unsupported' + +const setupKey = '__pascalEditorIwerSetup' +const deviceKey = '__pascalEditorIwerDevice' + +type EmulatedXRDevice = { + canvasContainer: HTMLDivElement + devui?: { + devUICanvas: HTMLCanvasElement + devUIContainer: HTMLDivElement + } +} + +export function getEmulatedXRDevice(): XRDevice | undefined { + return (globalThis as GlobalWithIwerSetup)[deviceKey] as XRDevice | undefined +} + +type GlobalWithIwerSetup = typeof globalThis & { + [deviceKey]?: EmulatedXRDevice + [setupKey]?: Promise +} + +export function prepareXRPlatform(): Promise { + const runtimeGlobal = globalThis as GlobalWithIwerSetup + runtimeGlobal[setupKey] ??= setupXRPlatform().catch((error: unknown) => { + delete runtimeGlobal[setupKey] + throw error + }) + return runtimeGlobal[setupKey] +} + +async function setupXRPlatform(): Promise { + if ((await getImmersiveVRSupport()) === 'supported') return 'native' + if (process.env.NODE_ENV !== 'development') return 'unsupported' + + const [{ XRDevice, metaQuest3 }, { DevUI }] = await Promise.all([ + import('iwer'), + import('@iwer/devui'), + ]) + const device = new XRDevice(metaQuest3) + device.installRuntime({ forceInstall: true }) + device.installDevUI(DevUI) + ;(globalThis as GlobalWithIwerSetup)[deviceKey] = device + + return (await getImmersiveVRSupport()) === 'supported' ? 'emulated' : 'unsupported' +} + +export function mountEmulatorControls(): () => void { + const device = (globalThis as GlobalWithIwerSetup)[deviceKey] + const devui = device?.devui + if (!(device && devui)) return () => undefined + + const host = device.canvasContainer + const mountedHost = !host.isConnected + const mountedCanvas = !devui.devUICanvas.isConnected + const mountedControls = !devui.devUIContainer.isConnected + + if (mountedCanvas) host.appendChild(devui.devUICanvas) + if (mountedControls) host.appendChild(devui.devUIContainer) + if (mountedHost) document.body.appendChild(host) + + return () => { + if (mountedCanvas && devui.devUICanvas.parentElement === host) devui.devUICanvas.remove() + if (mountedControls && devui.devUIContainer.parentElement === host) { + devui.devUIContainer.remove() + } + if (mountedHost && host.isConnected && host.childElementCount === 0) host.remove() + } +} diff --git a/apps/editor/lib/xr/preview-window.test.ts b/apps/editor/lib/xr/preview-window.test.ts new file mode 100644 index 0000000000..6df48606cc --- /dev/null +++ b/apps/editor/lib/xr/preview-window.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import { createXRPreviewSceneSnapshot } from './preview-window' + +describe('XR preview scene handoff', () => { + test('copies the complete live editor graph into the XR snapshot', () => { + const wall = { id: 'wall_1', parentId: 'level_1', type: 'wall' } + const state = { + collections: { shell: { id: 'shell', nodeIds: ['wall_1'] } }, + installedPlugins: ['@pascal-app/plugin-example'], + materials: { plaster: { id: 'plaster' } }, + nodes: { + level_1: { children: ['wall_1'], id: 'level_1', type: 'level' }, + wall_1: wall, + }, + rootNodeIds: ['level_1'], + } + + expect(createXRPreviewSceneSnapshot(state)).toEqual(state) + expect(createXRPreviewSceneSnapshot(state).nodes.wall_1).toBe(wall) + }) + + test('runs room-surface synchronization in the standalone XR editor', async () => { + const source = await Bun.file( + new URL('../../components/xr/xr-preview-environment.tsx', import.meta.url), + ).text() + + expect(source).toContain('initSpaceDetectionSync(useScene, useEditor)') + expect(source).toContain('unsubscribeSpaceDetection()') + }) +}) diff --git a/apps/editor/lib/xr/preview-window.ts b/apps/editor/lib/xr/preview-window.ts new file mode 100644 index 0000000000..055c5a29c3 --- /dev/null +++ b/apps/editor/lib/xr/preview-window.ts @@ -0,0 +1,32 @@ +import { useScene } from '@pascal-app/core' +import type { SceneGraph } from '@pascal-app/editor' + +export const XR_PREVIEW_SCENE_KEY = 'pascal-xr-preview-scene' + +type XRPreviewSceneState = Pick< + ReturnType, + 'collections' | 'installedPlugins' | 'materials' | 'nodes' | 'rootNodeIds' +> + +export function createXRPreviewSceneSnapshot(state: XRPreviewSceneState): SceneGraph { + const { collections, installedPlugins, materials, nodes, rootNodeIds } = state + return { collections, installedPlugins, materials, nodes, rootNodeIds } as SceneGraph +} + +export function openXRPreview(path: string) { + try { + localStorage.setItem( + XR_PREVIEW_SCENE_KEY, + JSON.stringify(createXRPreviewSceneSnapshot(useScene.getState())), + ) + } catch {} + + const url = new URL(path, window.location.href) + url.searchParams.set('source', 'live') + const preview = window.open( + `${url.pathname}${url.search}${url.hash}`, + 'pascal-xr-preview', + 'popup=yes,width=1280,height=800,resizable=yes,scrollbars=no', + ) + preview?.focus() +} diff --git a/apps/editor/lib/xr/reference-space-ray.test.ts b/apps/editor/lib/xr/reference-space-ray.test.ts new file mode 100644 index 0000000000..eb1614dcdd --- /dev/null +++ b/apps/editor/lib/xr/reference-space-ray.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { Euler, Matrix4, Plane, Quaternion, Ray, Vector3 } from 'three' +import { applyXRReferenceSpaceRayToWorld, setObjectFloorPlane } from './reference-space-ray' + +describe('applyXRReferenceSpaceRayToWorld', () => { + test('moves and rotates a raw XR pose with the active XR origin', () => { + const originMatrix = new Matrix4().compose( + new Vector3(0, 4.5, 8), + new Quaternion().setFromEuler(new Euler(0, Math.PI / 2, 0)), + new Vector3(2, 2, 2), + ) + const rayOrigin = new Vector3(0.25, 1.5, -0.4) + const rayDirection = new Vector3(0, 0, -1) + + applyXRReferenceSpaceRayToWorld(rayOrigin, rayDirection, originMatrix) + + expect(rayOrigin.x).toBeCloseTo(-0.8) + expect(rayOrigin.y).toBeCloseTo(7.5) + expect(rayOrigin.z).toBeCloseTo(7.5) + expect(rayDirection.x).toBeCloseTo(-1) + expect(rayDirection.y).toBeCloseTo(0) + expect(rayDirection.z).toBeCloseTo(0) + }) + + test('intersects the transformed active-level floor instead of global Y zero', () => { + const levelMatrix = new Matrix4().makeTranslation(0, 4.5, 0) + const floor = new Plane() + setObjectFloorPlane(floor, levelMatrix, new Vector3(), new Vector3()) + + const ray = new Ray(new Vector3(0, 6, 8), new Vector3(-2.5, -1.5, -8).normalize()) + const hit = ray.intersectPlane(floor, new Vector3()) + + expect(hit?.x).toBeCloseTo(-2.5) + expect(hit?.y).toBeCloseTo(4.5) + expect(hit?.z).toBeCloseTo(0) + }) +}) diff --git a/apps/editor/lib/xr/reference-space-ray.ts b/apps/editor/lib/xr/reference-space-ray.ts new file mode 100644 index 0000000000..8013426d1b --- /dev/null +++ b/apps/editor/lib/xr/reference-space-ray.ts @@ -0,0 +1,22 @@ +import type { Matrix4, Plane, Vector3 } from 'three' + +export function applyXRReferenceSpaceRayToWorld( + origin: Vector3, + direction: Vector3, + originMatrix: Matrix4, +) { + origin.applyMatrix4(originMatrix) + direction.transformDirection(originMatrix) +} + +export function setObjectFloorPlane( + plane: Plane, + objectMatrix: Matrix4, + point: Vector3, + normal: Vector3, +) { + plane.setFromNormalAndCoplanarPoint( + normal.set(0, 1, 0).transformDirection(objectMatrix), + point.set(0, 0, 0).applyMatrix4(objectMatrix), + ) +} diff --git a/apps/editor/lib/xr/settings.test.ts b/apps/editor/lib/xr/settings.test.ts new file mode 100644 index 0000000000..549aa82c71 --- /dev/null +++ b/apps/editor/lib/xr/settings.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeDefinition, ParametricDescriptor } from '@pascal-app/core' +import { + collectXRSettingRows, + createXRSettingPatch, + readXRSettingValue, + type XRSettingsContext, +} from './settings' + +function context( + node: AnyNode, + parametrics: ParametricDescriptor, + source: 'node' | 'tool' = 'node', +): XRSettingsContext { + return { + definition: { parametrics } as AnyNodeDefinition, + key: `${source}:test`, + node, + source, + title: 'Test', + ...(source === 'tool' ? { tool: node.type } : {}), + } +} + +describe('XR settings descriptors', () => { + test('flattens visible fields, vector axes, and node actions into one pageable list', () => { + const node = { + id: 'test_1', + type: 'test', + position: [1, 2, 3], + visible: true, + } as unknown as AnyNode + const parametrics = { + actions: [{ label: 'Reset', onClick: () => undefined }], + groups: [ + { + fields: [ + { key: 'position', kind: 'vec3', label: 'Position' }, + { key: 'visible', kind: 'boolean', label: 'Visible' }, + { key: 'hidden', kind: 'number', visibleIf: () => false }, + ], + label: 'Transform', + }, + ], + } as unknown as ParametricDescriptor + + const rows = collectXRSettingRows(context(node, parametrics)) + expect(rows.map((row) => row.label)).toEqual([ + 'Position X', + 'Position Y', + 'Position Z', + 'Visible', + 'Reset', + ]) + }) + + test('creates vector patches without mutating the source node', () => { + const node = { id: 'test_1', type: 'test', position: [1, 2, 3] } as unknown as AnyNode + const parametrics = { + groups: [{ fields: [{ key: 'position', kind: 'vec3' }], label: 'Transform' }], + } as unknown as ParametricDescriptor + const settings = context(node, parametrics) + const row = collectXRSettingRows(settings)[1] + if (row?.kind !== 'field') throw new Error('Expected field row') + + expect(readXRSettingValue(settings, row)).toBe(2) + expect(createXRSettingPatch(settings, row, 9)).toEqual({ position: [1, 9, 3] }) + expect((node as unknown as { position: number[] }).position).toEqual([1, 2, 3]) + }) + + test('runs the shared derive rule for placement defaults', () => { + const node = { id: 'test_1', type: 'test', width: 2, area: 4 } as unknown as AnyNode + const parametrics = { + derive: (next: AnyNode) => ({ + area: Number((next as unknown as { width: number }).width) ** 2, + }), + groups: [{ fields: [{ key: 'width', kind: 'number' }], label: 'Size' }], + } as unknown as ParametricDescriptor + const settings = context(node, parametrics, 'tool') + const row = collectXRSettingRows(settings)[0] + if (row?.kind !== 'field') throw new Error('Expected field row') + + expect(createXRSettingPatch(settings, row, 3)).toEqual({ area: 9, width: 3 }) + }) + + test('includes registry tool chips for spatial placement controls', () => { + const node = { id: 'test_1', type: 'test' } as unknown as AnyNode + const chip = { + cycle: () => undefined, + labels: { cabinet: 'Type: Cabinet', island: 'Type: Island' }, + subscribe: () => () => undefined, + value: () => 'cabinet', + } + const settings = { + ...context(node, { groups: [] }, 'tool'), + definition: { + parametrics: { groups: [] }, + toolHints: [{ chip, key: 'I', label: 'Placement type' }], + } as unknown as AnyNodeDefinition, + } + + expect(collectXRSettingRows(settings)).toEqual([ + expect.objectContaining({ kind: 'tool-chip', label: 'Placement type' }), + ]) + }) +}) diff --git a/apps/editor/lib/xr/settings.ts b/apps/editor/lib/xr/settings.ts new file mode 100644 index 0000000000..c5d65fe221 --- /dev/null +++ b/apps/editor/lib/xr/settings.ts @@ -0,0 +1,182 @@ +import { + type AnyNode, + type AnyNodeDefinition, + type AnyNodeId, + nodeRegistry, + type ParamAction, + type ParametricDescriptor, + type ParamField, + type ToolHint, +} from '@pascal-app/core' + +export type XRSettingsContext = { + definition: AnyNodeDefinition + key: string + node: AnyNode + source: 'node' | 'tool' + title: string + tool?: string +} + +export type XRSettingFieldRow = { + axis?: number + field: ParamField + group: string + id: string + kind: 'field' + label: string +} + +export type XRSettingActionRow = { + action: ParamAction + id: string + kind: 'action' + label: string +} + +export type XRSettingToolChipRow = { + hint: ToolHint & { chip: NonNullable } + id: string + kind: 'tool-chip' + label: string +} + +export type XRSettingRow = XRSettingActionRow | XRSettingFieldRow | XRSettingToolChipRow + +type ResolveXRSettingsContextInput = { + mode: string + selectedNode?: AnyNode + tool: string | null + toolDefaults?: Readonly> +} + +export function resolveXRSettingsContext({ + mode, + selectedNode, + tool, + toolDefaults, +}: ResolveXRSettingsContextInput): XRSettingsContext | null { + if (selectedNode) { + const definition = nodeRegistry.get(selectedNode.type) + if (!definition) return null + return { + definition, + key: `node:${selectedNode.id}`, + node: selectedNode, + source: 'node', + title: definition.presentation?.label ?? selectedNode.type, + } + } + + if (mode !== 'build' || !tool) return null + const definition = nodeRegistry.get(tool) + if (!definition) return null + const defaults = definition.defaults() as Record + const node = { + ...defaults, + ...toolDefaults, + id: `xr-tool-default:${tool}` as AnyNodeId, + type: tool, + } as AnyNode + return { + definition, + key: `tool:${tool}`, + node, + source: 'tool', + title: `${definition.presentation?.label ?? tool} defaults`, + tool, + } +} + +export function collectXRSettingRows(context: XRSettingsContext): XRSettingRow[] { + const parametrics = context.definition.parametrics as ParametricDescriptor | undefined + const rows: XRSettingRow[] = [] + + if (context.source === 'tool') { + context.definition.toolHints?.forEach((hint, index) => { + if (!hint.chip || (hint.visible && !hint.visible.value())) return + rows.push({ + hint: hint as ToolHint & { chip: NonNullable }, + id: `tool-chip-${index}-${hint.label}`, + kind: 'tool-chip', + label: hint.label, + }) + }) + } + + if (!parametrics) return rows + + parametrics.groups.forEach((group, groupIndex) => { + group.fields.forEach((rawField, fieldIndex) => { + const field = rawField as ParamField + if (field.visibleIf) { + try { + if (!field.visibleIf(context.node)) return + } catch { + return + } + } + const label = field.label ?? String(field.key) + const id = `${groupIndex}-${fieldIndex}-${String(field.key)}` + if (field.kind === 'vec3') { + for (let axis = 0; axis < 3; axis += 1) { + rows.push({ + axis, + field, + group: group.label, + id: `${id}-${axis}`, + kind: 'field', + label: `${label} ${'XYZ'[axis]}`, + }) + } + return + } + rows.push({ field, group: group.label, id, kind: 'field', label }) + }) + }) + + if (context.source === 'node') { + parametrics.actions?.forEach((action, index) => { + rows.push({ + action: action as ParamAction, + id: `action-${index}-${action.label}`, + kind: 'action', + label: action.label, + }) + }) + } + + return rows +} + +export function readXRSettingValue(context: XRSettingsContext, row: XRSettingFieldRow): unknown { + const value = (context.node as unknown as Record)[String(row.field.key)] + if (row.field.kind !== 'vec3') return value + return Array.isArray(value) ? value[row.axis ?? 0] : undefined +} + +export function createXRSettingPatch( + context: XRSettingsContext, + row: XRSettingFieldRow, + value: unknown, +): Record { + const key = String(row.field.key) + let patch: Record + if (row.field.kind === 'vec3') { + const current = (context.node as unknown as Record)[key] + const vector = Array.isArray(current) ? [...current] : [0, 0, 0] + vector[row.axis ?? 0] = value + patch = { [key]: vector } + } else { + patch = { [key]: value } + } + + if (context.source !== 'tool') return patch + const parametrics = context.definition.parametrics as ParametricDescriptor | undefined + if (!parametrics?.derive) return patch + const next = { ...context.node, ...patch } as AnyNode + return { + ...patch, + ...parametrics.derive(next, patch as Partial, context.node), + } +} diff --git a/apps/editor/lib/xr/wand-panel-settings.test.ts b/apps/editor/lib/xr/wand-panel-settings.test.ts new file mode 100644 index 0000000000..30bc7fe1e3 --- /dev/null +++ b/apps/editor/lib/xr/wand-panel-settings.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + useXRWandPanelSettings, + XR_WAND_PANEL_SCALE_MAX, + XR_WAND_PANEL_SCALE_MIN, +} from './wand-panel-settings' + +describe('XR wand panel settings', () => { + beforeEach(() => + useXRWandPanelSettings.setState({ + buildPage: 0, + buildSection: 'main', + paintCategoryIndex: 0, + paintPage: 0, + panelScale: 1, + settingsContextKey: '', + settingsPage: 0, + terrainPage: 0, + }), + ) + + test('preserves build navigation outside the remounting input subtree', () => { + const { setBuildNavigation } = useXRWandPanelSettings.getState() + + setBuildNavigation('roof', 2) + expect(useXRWandPanelSettings.getState()).toMatchObject({ + buildPage: 2, + buildSection: 'roof', + }) + + setBuildNavigation('main', -1) + expect(useXRWandPanelSettings.getState()).toMatchObject({ + buildPage: 0, + buildSection: 'main', + }) + }) + + test('preserves every paginated panel across input remounts', () => { + const { setPaintNavigation, setSettingsNavigation, setTerrainPage } = + useXRWandPanelSettings.getState() + + setPaintNavigation(2, 3) + setSettingsNavigation('wall:wall-1', 4) + setTerrainPage(1) + + expect(useXRWandPanelSettings.getState()).toMatchObject({ + paintCategoryIndex: 2, + paintPage: 3, + settingsContextKey: 'wall:wall-1', + settingsPage: 4, + terrainPage: 1, + }) + }) + + test('updates panel size in stable decimal steps', () => { + const { setPanelScale } = useXRWandPanelSettings.getState() + + setPanelScale(1.1) + expect(useXRWandPanelSettings.getState().panelScale).toBe(1.1) + setPanelScale(1.200_000_000_000_000_2) + expect(useXRWandPanelSettings.getState().panelScale).toBe(1.2) + }) + + test('clamps the reference project scale range and rejects non-finite input', () => { + const { setPanelScale } = useXRWandPanelSettings.getState() + + setPanelScale(99) + expect(useXRWandPanelSettings.getState().panelScale).toBe(XR_WAND_PANEL_SCALE_MAX) + setPanelScale(0) + expect(useXRWandPanelSettings.getState().panelScale).toBe(XR_WAND_PANEL_SCALE_MIN) + setPanelScale(Number.NaN) + expect(useXRWandPanelSettings.getState().panelScale).toBe(XR_WAND_PANEL_SCALE_MIN) + }) +}) diff --git a/apps/editor/lib/xr/wand-panel-settings.ts b/apps/editor/lib/xr/wand-panel-settings.ts new file mode 100644 index 0000000000..2c09b5bf7f --- /dev/null +++ b/apps/editor/lib/xr/wand-panel-settings.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand' + +export const XR_WAND_PANEL_SCALE_MIN = 0.65 +export const XR_WAND_PANEL_SCALE_MAX = 1.6 +export const XR_WAND_PANEL_SCALE_STEP = 0.1 + +export type XRWandBuildSection = 'main' | 'mep' | 'roof' + +type XRWandPanelSettingsState = { + buildPage: number + buildSection: XRWandBuildSection + paintCategoryIndex: number + paintPage: number + panelScale: number + settingsContextKey: string + settingsPage: number + terrainPage: number + setBuildNavigation: (buildSection: XRWandBuildSection, buildPage: number) => void + setPaintNavigation: (paintCategoryIndex: number, paintPage: number) => void + setPanelScale: (panelScale: number) => void + setSettingsNavigation: (settingsContextKey: string, settingsPage: number) => void + setTerrainPage: (terrainPage: number) => void +} + +export const useXRWandPanelSettings = create((set) => ({ + buildPage: 0, + buildSection: 'main', + paintCategoryIndex: 0, + paintPage: 0, + panelScale: 1, + settingsContextKey: '', + settingsPage: 0, + terrainPage: 0, + setBuildNavigation: (buildSection, buildPage) => { + set({ buildPage: Math.max(0, Math.floor(buildPage)), buildSection }) + }, + setPaintNavigation: (paintCategoryIndex, paintPage) => { + set({ + paintCategoryIndex: Math.max(0, Math.floor(paintCategoryIndex)), + paintPage: Math.max(0, Math.floor(paintPage)), + }) + }, + setPanelScale: (panelScale) => { + if (!Number.isFinite(panelScale)) return + set({ + panelScale: Math.min( + XR_WAND_PANEL_SCALE_MAX, + Math.max(XR_WAND_PANEL_SCALE_MIN, Math.round(panelScale * 100) / 100), + ), + }) + }, + setSettingsNavigation: (settingsContextKey, settingsPage) => { + set({ settingsContextKey, settingsPage: Math.max(0, Math.floor(settingsPage)) }) + }, + setTerrainPage: (terrainPage) => { + set({ terrainPage: Math.max(0, Math.floor(terrainPage)) }) + }, +})) diff --git a/apps/editor/lib/xr/wand-panel.test.ts b/apps/editor/lib/xr/wand-panel.test.ts new file mode 100644 index 0000000000..be8ab65d23 --- /dev/null +++ b/apps/editor/lib/xr/wand-panel.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import { Euler, Vector3 } from 'three' +import { + getPage, + getPageWithPinnedFirst, + resolveWandPanelFacePose, +} from '@/components/xr/wand-panel/panel-layout' + +describe('XR wand panel layout', () => { + test('does not mount Drei line or text materials in the immersive panel', async () => { + const files = [ + 'build-panel.tsx', + 'paint-panel.tsx', + 'settings-panel.tsx', + 'spatial-controls.tsx', + 'wand-panel.tsx', + ] + const sources = await Promise.all( + files.map((file) => + Bun.file(new URL(`../../components/xr/wand-panel/${file}`, import.meta.url)).text(), + ), + ) + + expect(sources.join('\n')).not.toMatch( + /import\s*\{[^}]*(?:\bLine\b|\bText\b)[^}]*\}\s*from\s*['"]@react-three\/drei['"]/, + ) + }) + + test('keeps panel switching controls out of the panel faces', async () => { + const source = await Bun.file( + new URL('../../components/xr/wand-panel/wand-panel.tsx', import.meta.url), + ).text() + + expect(source).not.toContain('RingArrows') + expect(source).toContain('pointerEventsOrder={100}') + expect(source).toContain("pointerEventsType={{ deny: 'grab' }}") + }) + + test('puts spatial button handlers on the raycastable mesh', async () => { + const source = await Bun.file( + new URL('../../components/xr/wand-panel/spatial-controls.tsx', import.meta.url), + ).text() + const buttonSource = source.slice( + source.indexOf('export function SpatialButton'), + source.indexOf('export function PanelFace'), + ) + + expect(buttonSource).toMatch(/ { + const source = await Bun.file( + new URL('../../components/xr/wand-panel/build-panel.tsx', import.meta.url), + ).text() + + expect(source).toContain("iconSrc: '/icons/select.webp'") + expect(source.match(/selectEntry,/g)).toHaveLength(3) + expect( + getPageWithPinnedFirst(['select', ...Array.from({ length: 17 }, (_, i) => i)], 1, 9), + ).toEqual({ + currentPage: 1, + items: ['select', 8, 9, 10, 11, 12, 13, 14, 15], + pageCount: 3, + }) + }) + + test('mirrors the ring faces for the opposite hand', () => { + const left = resolveWandPanelFacePose(1, 'left') + const right = resolveWandPanelFacePose(1, 'right') + + expect(right.position[0]).toBeCloseTo(-left.position[0]) + expect(right.position[1]).toBeCloseTo(left.position[1]) + expect(right.rotation[1]).toBeCloseTo(-left.rotation[1]) + }) + + test('matches the reference three-face ring at 120 degrees', () => { + const normals = [0, 1, 2].map((index) => { + const pose = resolveWandPanelFacePose(index, 'left') + expect(pose.position[2]).toBeCloseTo(0) + const normal = new Vector3(0, 0, 1).applyEuler(new Euler(...pose.rotation)) + expect(normal.z).toBeCloseTo(0) + return normal + }) + + expect(normals[0]!.dot(normals[1]!)).toBeCloseTo(-0.5) + expect(normals[1]!.dot(normals[2]!)).toBeCloseTo(-0.5) + expect(normals[2]!.dot(normals[0]!)).toBeCloseTo(-0.5) + }) + + test('clamps nested palette pages', () => { + expect(getPage([1, 2, 3, 4, 5], 9, 2)).toEqual({ + currentPage: 2, + items: [5], + pageCount: 3, + }) + }) +}) diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 48578416b9..3ac05105c5 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -6,6 +6,7 @@ const appDirectory = path.dirname(fileURLToPath(import.meta.url)) const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1' const nextConfig: NextConfig = { + allowedDevOrigins: ['192.168.0.102'], ...(portableBuild ? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') } : {}), @@ -39,11 +40,20 @@ const nextConfig: NextConfig = { '@dgreenheck/ez-tree', ], turbopack: { + // Include the editor and locally linked sibling plugin without watching the whole home folder. + root: path.join(appDirectory, '../../..'), resolveAlias: { - react: './node_modules/react', - three: './node_modules/three', - '@react-three/fiber': './node_modules/@react-three/fiber', - '@react-three/drei': './node_modules/@react-three/drei', + '@pascal-app/core': '../../packages/core/src/index.ts', + '@pascal-app/editor': '../../packages/editor/src/index.tsx', + '@pascal-app/viewer': '../../packages/viewer/src/index.ts', + '@pascal-local/plugin-webxr': '../../../webxr-pascal-plugin/src/index.ts', + react: '../../node_modules/react', + three: '../../node_modules/three', + // TSL and the renderer must share one module-level shader stack. + 'three/webgpu': '../../node_modules/three/build/three.webgpu.js', + 'three/tsl': '../../node_modules/three/build/three.tsl.js', + '@react-three/fiber': '../../node_modules/@react-three/fiber', + '@react-three/drei': '../../node_modules/@react-three/drei', }, }, experimental: { diff --git a/apps/editor/package.json b/apps/editor/package.json index 31b4cde8a5..7a2e26d1a2 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -5,6 +5,7 @@ "private": true, "scripts": { "dev": "dotenv -e ../../.env.local -e ../../.env.defaults -- next dev", + "dev:xr": "dotenv -e ../../.env.local -e ../../.env.defaults -- next dev --hostname 0.0.0.0 --experimental-https", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", @@ -26,6 +27,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "geist": "^1.7.0", @@ -40,12 +42,14 @@ "zod": ">=4.5.4 <4.6" }, "devDependencies": { + "@iwer/devui": "2.3.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", + "iwer": "2.3.0", "react-grab": "^0.1.50", "react-scan": "^0.5.7", "tw-animate-css": "^1.4.0", diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index 70924e110d..f3087c89dc 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -7,7 +7,11 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@pascal-app/core": ["../../packages/core/src/index.ts"], + "@pascal-app/editor": ["../../packages/editor/src/index.tsx"], + "@pascal-app/viewer": ["../../packages/viewer/src/index.ts"], + "@pascal-local/plugin-webxr": ["../../../webxr-pascal-plugin/src/index.ts"] } }, "include": [ diff --git a/bun.lock b/bun.lock index a7c5c711c9..6ef2616c54 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "geist": "^1.7.0", @@ -55,12 +56,14 @@ "zod": ">=4.5.4 <4.6", }, "devDependencies": { + "@iwer/devui": "2.3.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", + "iwer": "2.3.0", "react-grab": "^0.1.50", "react-scan": "^0.5.7", "tw-animate-css": "^1.4.0", @@ -349,6 +352,7 @@ "name": "@pascal-app/viewer", "version": "1.0.0-beta.5", "dependencies": { + "@react-three/xr": "^6.6.30", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5", @@ -376,6 +380,10 @@ "version": "0.0.0", }, }, + "patchedDependencies": { + "three@0.185.1": "patches/three@0.185.1.patch", + "iwer@2.3.0": "patches/iwer@2.3.0.patch", + }, "overrides": { "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -439,6 +447,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.1", "", {}, "sha512-agRJn3+EJDUe8AvxTx/LnHA/GErvLE62pSaSk7+MwFOtOv8eWBu/qCq2qoZjBjVZ3C2aiFJCveuSs17KMkYGOw=="], + "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], @@ -463,6 +473,10 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -489,6 +503,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.6.0", "", {}, "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw=="], + + "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], + + "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA=="], + + "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@0.2.2", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~1 || ~6", "react": ">=16.3" } }, "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g=="], + "@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="], "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "ktx-parse": "^1.1.0" } }, "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ=="], @@ -567,6 +589,10 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@iwer/devui": ["@iwer/devui@2.3.0", "", { "dependencies": { "@pmndrs/handle": "^6.6.29", "@pmndrs/pointer-events": "^6.6.29", "lucide-react": "^1.20.0", "react": "^19.2.6", "react-dom": "^19.2.6", "styled-components": "^6.4.1", "three": "^0.184.0", "zustand": "^5.0.13" }, "peerDependencies": { "iwer": "^2.3.0" } }, "sha512-UfBFR3qOYt/DUsLik20uF7Jn0NrNN2L0+uiDGJzuKXpXa/2btNOjOj1iSaTthz95MK5/O2HqqC+ymjkRMHi5UQ=="], + + "@iwer/sem": ["@iwer/sem@0.2.5", "", { "dependencies": { "three": "^0.165.0", "ts-proto": "^2.6.0" }, "peerDependencies": { "iwer": "^2.0.0" } }, "sha512-vMCfpu/7Qqc+hkBiGD9pxjeObgrhXOrL0KX94CA3yzJaU0dq0y49HXZT6fC+6X/jOmjaM3hjyE1m2h7ZmLzzyA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -795,6 +821,12 @@ "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], + "@pmndrs/handle": ["@pmndrs/handle@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "zustand": "^4.5.2" } }, "sha512-KPutdaLpCNPZoqXX2HvlRc+cQMETXdHTF0nKqFHMrJrne34apqELRVig53lO469rJhheedsRVepfdyo4g9Rukw=="], + + "@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.30", "", {}, "sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g=="], + + "@pmndrs/xr": ["@pmndrs/xr@6.6.30", "", { "dependencies": { "@iwer/devui": "^1.1.1", "@iwer/sem": "~0.2.5", "@pmndrs/pointer-events": "~6.6.30", "iwer": "^2.1.0", "meshline": "^3.3.1", "zustand": "^4.5.2" }, "peerDependencies": { "three": "*" } }, "sha512-qy0UQHaXdZs192awbYkde+O/eKn15fz88X08+7EhlVIdsMfPAORR2JDocTzfYmtz8bKyE4z1JMUUvXb9DyUrzg=="], + "@preact/signals": ["@preact/signals@2.9.1", "", { "dependencies": { "@preact/signals-core": "^1.14.0" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-xVqN8mJjbSN5IB/8Ubmd9NN+Ew6zJswoRxrjZbH3YsgkMshFeO6d8zxEFpHRTq9GJZx7cnPs2CnCpFqtGXGNsw=="], "@preact/signals-core": ["@preact/signals-core@1.14.2", "", {}, "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A=="], @@ -883,6 +915,8 @@ "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], + "@react-three/xr": ["@react-three/xr@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "@pmndrs/xr": "~6.6.30", "suspend-react": "^0.1.3", "tunnel-rat": "^0.1.2", "zustand": "^4.5.2" }, "peerDependencies": { "@react-three/fiber": ">=8", "react": ">=18", "react-dom": ">=18", "three": "*" } }, "sha512-C+PYxnDsWvF2WG669DXwlcKnxcy7O+FMhxlaOq75HrRSJRtk4T5iZCv6qak2+8ztcYrip8t79W9E+LSV3P93hA=="], + "@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"], "@repo/typescript-config": ["@repo/typescript-config@workspace:packages/typescript-config"], @@ -1157,10 +1191,14 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], + "case-anything": ["case-anything@2.1.13", "", {}, "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1209,6 +1247,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="], @@ -1255,6 +1297,8 @@ "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "dprint-node": ["dprint-node@1.0.8", "", { "dependencies": { "detect-libc": "^1.0.3" } }, "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg=="], + "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1413,6 +1457,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1551,6 +1597,8 @@ "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], + "iwer": ["iwer@2.3.0", "", { "dependencies": { "gl-matrix": "^3.4.4", "webxr-layers-polyfill": "^1.1.0" } }, "sha512-+aY/BXVIjztCtS4F1hAO2rQy0P1/0JbJ6Jq5QHVscUBBlv8xJlTkkyeg+uSiD9RQ7i5B6k1Blpcx84FQaI7Myw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -1625,7 +1673,7 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "lucide-react": ["lucide-react@1.41.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], @@ -1779,6 +1827,8 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], @@ -1925,8 +1975,12 @@ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "styled-components": ["styled-components@6.5.3", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "stylis": "4.3.6" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0", "react-native": ">= 0.68.0" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1969,6 +2023,12 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-poet": ["ts-poet@6.12.0", "", { "dependencies": { "dprint-node": "^1.0.8" } }, "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA=="], + + "ts-proto": ["ts-proto@2.12.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.14.1", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" }, "bin": { "protoc-gen-ts_proto": "protoc-gen-ts_proto" } }, "sha512-osbffME+UulBWYF+dNhOzQqCTxb43BsAIfGwPaCyYp47HJS/F83mR4OUGuknc5VGR6jYBcXxPdIUe98/H7KhRg=="], + + "ts-proto-descriptors": ["ts-proto-descriptors@2.1.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], @@ -2049,6 +2109,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webxr-layers-polyfill": ["webxr-layers-polyfill@1.1.0", "", { "dependencies": { "gl-matrix": "^3.4.3" } }, "sha512-GqWE6IFlut8a1Lnh9t1RPnOXud1rZ7wLPvWp7mqTDOYtgorXqlNMhEnI9EqjU33grBx0v3jm0Oc13opkAdmgMQ=="], + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -2099,12 +2161,20 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@pmndrs/handle/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + + "@pmndrs/xr/@iwer/devui": ["@iwer/devui@1.1.2", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", "@pmndrs/handle": "^6.6.17", "@pmndrs/pointer-events": "^6.6.17", "react": ">=18.3.1", "react-dom": ">=18.3.1", "styled-components": "^6.1.13", "three": "^0.165.0" }, "peerDependencies": { "iwer": "^2.0.1" } }, "sha512-ggF1lXSX14BTYP0QzB4xaurySr2PC+3+rtK/dpCR++giWquzFv2mBw3LW/PaCtdl5mqkZMrQ2GSwfUNg9ZoO+w=="], + + "@pmndrs/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@react-grab/cli/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], + "@react-three/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -2139,6 +2209,8 @@ "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dprint-node/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "editor/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], diff --git a/package.json b/package.json index 56f12089ba..d061e242b0 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "turbo run build", "dev": "dotenv -e ./.env -e ./.env.defaults -- turbo run dev --env-mode=loose", + "dev:xr": "bun run --cwd apps/editor dev:xr", "lint": "biome lint", "lint:fix": "biome lint --write", "format": "biome format --write", @@ -57,6 +58,10 @@ "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0" }, + "patchedDependencies": { + "three@0.185.1": "patches/three@0.185.1.patch", + "iwer@2.3.0": "patches/iwer@2.3.0.patch" + }, "workspaces": [ "apps/*", "packages/*", diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx index 093a2f9fd1..26098ae552 100644 --- a/packages/editor/src/components/editor/editor-layout-mobile.tsx +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -40,6 +40,7 @@ export interface EditorLayoutMobileProps { viewerToolbarRight?: ReactNode viewerContent: ReactNode overlays?: ReactNode + immersivePresentation?: boolean } export function EditorLayoutMobile({ @@ -51,6 +52,7 @@ export function EditorLayoutMobile({ viewerToolbarRight, viewerContent, overlays, + immersivePresentation = false, }: EditorLayoutMobileProps) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const activePanel = useEditor((s) => s.activeSidebarPanel) @@ -189,11 +191,12 @@ export function EditorLayoutMobile({ // Otherwise, the viewer extends SHEET_OVERLAP_PX behind the sheet's rounded // corners so the curve reveals viewer content underneath. const baseViewerHeight = Math.max(0, middleH - effectiveSheetH) - const viewerHeight = isCaptureMode - ? middleH - : baseViewerHeight === 0 - ? 0 - : Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX) + const viewerHeight = + isCaptureMode || immersivePresentation + ? middleH + : baseViewerHeight === 0 + ? 0 + : Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX) // While the panel sheet is open, collapse the primary sheet to its handle so // it doesn't peek above. Remember the previous height and restore it on close. @@ -212,8 +215,11 @@ export function EditorLayoutMobile({ }, [panelSheetHeight, committedSheetH]) return ( -
- {navbarSlot} +
+ {!immersivePresentation && navbarSlot}
- {(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && ( -
-
- {viewerToolbarLeft} + {(viewerToolbarLeft || viewerToolbarRight) && + !(isCaptureMode || immersivePresentation) && ( +
+
+ {viewerToolbarLeft} +
+
+ {viewerToolbarRight} +
-
- {viewerToolbarRight} -
-
- )} + )}
{viewerContent}
- {overlays && ( + {overlays && !immersivePresentation && (
{/* Bottom sheet: overlays the lower part of the middle area */} - {!isCaptureMode && sidebarTabs.length > 0 && ( + {!(isCaptureMode || immersivePresentation) && sidebarTabs.length > 0 && ( - {!isCaptureMode && sidebarTabs.length > 0 && ( + {!(isCaptureMode || immersivePresentation) && sidebarTabs.length > 0 && ( )}
diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index c2d85c052e..8c3fb9f46a 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -165,20 +165,24 @@ function RightColumn({ children, overlays, stageOverlay, + immersivePresentation = false, }: { toolbarLeft?: ReactNode toolbarRight?: ReactNode children: ReactNode overlays?: ReactNode stageOverlay?: ReactNode + immersivePresentation?: boolean }) { return (
{/* Viewer toolbar */} @@ -228,6 +232,8 @@ export interface EditorLayoutV2Props { viewerContent: ReactNode overlays?: ReactNode stageOverlay?: ReactNode + /** Show only the viewer while an immersive session is presenting. */ + immersivePresentation?: boolean } export function EditorLayoutV2({ @@ -240,6 +246,7 @@ export function EditorLayoutV2({ viewerContent, overlays, stageOverlay, + immersivePresentation = false, }: EditorLayoutV2Props) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const isMobile = useIsMobile() @@ -247,6 +254,7 @@ export function EditorLayoutV2({ if (isMobile) { return ( +
{/* Top navbar */} - {navbarSlot} + {!immersivePresentation && navbarSlot} {/* Main content: left column + right column */}
- {!isCaptureMode && sidebarTabs.length > 0 && ( + {!(isCaptureMode || immersivePresentation) && sidebarTabs.length > 0 && ( )} {viewerContent} diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index 8649aa2d4d..66e11ea0b0 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -18,6 +18,8 @@ import { getMovingNode } from '../../store/use-interaction-scope' // about to snap into lights up. const PLACEMENT_REVEAL_RADIUS = 12 +export const EDITOR_GRID_INPUT_NAME = 'pascal-editor-grid-input' + const UP = new Vector3(0, 1, 0) // PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal // → world +Y), equivalent to the old `rotation-x={-π/2}`. @@ -315,6 +317,7 @@ export const Grid = ({ geometry={geometry} layers={GRID_LAYER} material={material} + name={EDITOR_GRID_INPUT_NAME} ref={gridRef} renderOrder={1} /> diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 0503b4335e..74025e8b06 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -13,10 +13,11 @@ import { import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' +import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' +import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' import useEditor from '../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, @@ -163,6 +164,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: if (event.button !== 0) return event.stopPropagation() suppressBoxSelectForPointer(event) + const spatialPointerId = getSpatialPointerId(event.nativeEvent) + const spatialRay = spatialPointerId ? event.ray.clone() : null + if (spatialPointerId) { + const target = event.object as typeof event.object & { + setPointerCapture?: (pointerId: number) => void + } + target.setPointerCapture?.(event.pointerId) + } frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } const center = rest.pivot.clone() @@ -213,10 +222,12 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: ) } - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) + if (!spatialRay) { + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + } const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return + if (!(spatialRay ?? raycaster.ray).intersectPlane(plane, hit)) return const initialAngle = angleOf(hit) document.body.style.cursor = 'grabbing' @@ -230,15 +241,13 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: }) setIsDragging(true) - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) + const applyRay = (ray: Ray, freeRotation: boolean) => { const moveHit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, moveHit)) return + if (!ray.intersectPlane(plane, moveHit)) return let delta = angleOf(moveHit) - initialAngle while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI - if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP + if (!freeRotation) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP // Shared rigid-rotation math (also used by the keyboard group R/T); // see `rotateGroupPatches` for the orbit/yaw handedness contract. @@ -286,8 +295,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: }) } } + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + applyRay(raycaster.ray, e.shiftKey) + } const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + let releaseSpatialCapture: (() => void) | null = null const clearLivePreviews = () => { const overrides = useLiveNodeOverrides.getState() const liveTransforms = useLiveTransforms.getState() @@ -303,6 +318,8 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) + releaseSpatialCapture?.() + releaseSpatialCapture = null if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) @@ -366,6 +383,16 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: for (const id of affectedIds) { useLiveTransforms.getState().clear(id) } + if (spatialPointerId && spatialRay) { + releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, { + onMove: (ray) => { + spatialRay.copy(ray) + applyRay(spatialRay, false) + }, + onRelease: onUp, + onCancel, + }) + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) @@ -398,6 +425,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: onPointerDown={activate} onPointerEnter={onHoverEnter} onPointerLeave={onHoverLeave} + pointerEventsOrder={10} scale={baseScale} /> diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index eecf61bf7a..83e9aa2812 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -1,7 +1,7 @@ 'use client' import { type Cursor, emitter } from '@pascal-app/core' -import type { ThreeEvent } from '@react-three/fiber' +import { type ThreeEvent, useThree } from '@react-three/fiber' import { type ReactNode, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, @@ -14,14 +14,18 @@ import { type Group, type Intersection, Mesh, + type Object3D, + type Ray, type Raycaster, Shape, TorusGeometry, + Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation' +import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input' import useEditor from '../../../store/use-editor' // While a press-drag move is in flight (`placementDragMode`), the move tool @@ -42,6 +46,7 @@ export const NO_RAYCAST = () => null export const HIT_AREA_MARGIN = 0.035 const HIT_AREA_RENDER_ORDER = 1011 +const HIT_AREA_POINTER_EVENTS_ORDER = 10 const HIT_AREA_THICKNESS = 0.08 const CHEVRON_MIN_X = -0.2 const CHEVRON_MAX_X = 0.22 @@ -423,15 +428,63 @@ export function InvisibleHandleHitArea({ onPointerLeave: PointerHandler scale: number }) { + const camera = useThree((state) => state.camera) + const canvas = useThree((state) => state.gl.domElement) + + const windowPointerEventForRay = ( + type: 'pointermove' | 'pointerup' | 'pointercancel', + ray: Ray, + ) => { + const point = ray.at(4, new Vector3()).project(camera) + const rect = canvas.getBoundingClientRect() + return new PointerEvent(type, { + bubbles: true, + button: 0, + buttons: type === 'pointermove' ? 1 : 0, + clientX: rect.left + ((point.x + 1) / 2) * rect.width, + clientY: rect.top + ((1 - point.y) / 2) * rect.height, + pointerType: 'xr', + }) + } + + const handlePointerDown: PointerHandler = (event) => { + const spatialPointerId = getSpatialPointerId(event.nativeEvent) + if (spatialPointerId) { + const target = event.object as Object3D & { + setPointerCapture?: (pointerId: number) => void + } + target.setPointerCapture?.(event.pointerId) + const initialPointer = windowPointerEventForRay('pointermove', event.ray) + const nativeEvent = event.nativeEvent as PointerEvent + try { + Object.defineProperties(nativeEvent, { + clientX: { configurable: true, value: initialPointer.clientX }, + clientY: { configurable: true, value: initialPointer.clientY }, + pointerId: { configurable: true, value: event.pointerId }, + pointerType: { configurable: true, value: 'xr' }, + }) + } catch { + // Direct-ray handle sessions do not need projected DOM coordinates. + } + spatialPointerInput.capture(spatialPointerId, { + onMove: (ray) => window.dispatchEvent(windowPointerEventForRay('pointermove', ray)), + onRelease: () => window.dispatchEvent(windowPointerEventForRay('pointerup', event.ray)), + onCancel: () => window.dispatchEvent(windowPointerEventForRay('pointercancel', event.ray)), + }) + } + onPointerDown(event) + } + return ( restore() +} + +function getSpatialPointerSource(event: ThreeEvent): object | null { + const pointerId = getSpatialPointerId(event.nativeEvent) + return typeof pointerId === 'object' ? pointerId : null } export function useHandleDrag(args: UseHandleDragArgs) { @@ -120,9 +127,17 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (event.button !== 0) return event.stopPropagation() suppressBoxSelectForPointer(event) + const spatialPointerSource = getSpatialPointerSource(event) if (args.kind === 'tap') { - suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId) + const restoreInputDragging = suppressInputDraggingUntilPointerRelease(event.pointerId) + if (spatialPointerSource) { + spatialPointerInput.capture(spatialPointerSource, { + onMove: () => undefined, + onRelease: restoreInputDragging, + onCancel: restoreInputDragging, + }) + } swallowNextClick() sfxEmitter.emit('sfx:item-pick') document.body.style.cursor = '' @@ -132,6 +147,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args rideObject.updateMatrixWorld() + const spatialRay = spatialPointerSource ? event.ray.clone() : null const ndc = new Vector2() const setPointerRay = (clientX: number, clientY: number) => { @@ -143,10 +159,12 @@ export function useHandleDrag(args: UseHandleDragArgs) { raycaster.setFromCamera(ndc, camera) } const getPointerRay: GetPointerRay = (clientX, clientY, target) => { + if (spatialRay) return target.copy(spatialRay) setPointerRay(clientX, clientY) return target.copy(raycaster.ray) } const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { + if (spatialRay) return spatialRay.intersectPlane(plane, target) setPointerRay(clientX, clientY) return raycaster.ray.intersectPlane(plane, target) } @@ -180,6 +198,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { let lastPatch: Partial | null = null let historyPaused = true let altKey = event.nativeEvent.altKey + let releaseSpatialCapture: (() => void) | null = null const resumeHistory = () => { if (!historyPaused) return @@ -208,6 +227,8 @@ export function useHandleDrag(args: UseHandleDragArgs) { window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('keyup', onKeyUp, true) + releaseSpatialCapture?.() + releaseSpatialCapture = null if (document.body.style.cursor === cursor) { document.body.style.cursor = '' } @@ -264,6 +285,23 @@ export function useHandleDrag(args: UseHandleDragArgs) { } dragCleanupRef.current = onCancel + if (spatialPointerSource && spatialRay) { + releaseSpatialCapture = spatialPointerInput.capture(spatialPointerSource, { + onMove: (ray) => { + spatialRay.copy(ray) + onMove( + new PointerEvent('pointermove', { + button: 0, + buttons: 1, + pointerId: event.pointerId, + pointerType: 'xr', + }), + ) + }, + onRelease: onUp, + onCancel, + }) + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index aafb678a0d..736821655b 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -19,6 +19,7 @@ import { SceneEnvironment, useViewer, Viewer, + type ViewerXRConfig, } from '@pascal-app/viewer' import { memo, @@ -217,6 +218,14 @@ export interface EditorProps { * module-load URL flags or shading toggles. */ disablePostFx?: boolean + /** Use the viewer's WebGL backend for host features such as immersive WebXR. */ + forceWebGL?: boolean + + /** Host-provided immersive XR runtime for the main 3D canvas. */ + xr?: ViewerXRConfig + + /** Hide authoring chrome and let the viewer fill the host while XR presents. */ + immersivePresentation?: boolean // Version preview overlays (rendered by host app) sidebarOverlay?: ReactNode @@ -767,6 +776,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isVersionPreviewMode, isLoading, isFirstPersonMode, + isXRMode, isStudioMode, onThumbnailCapture, viewerSceneSlot, @@ -774,6 +784,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean + isXRMode: boolean isStudioMode: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void viewerSceneSlot?: ReactNode @@ -784,11 +795,12 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ // selection, editing handles, and the tool manager (which mounts the site // boundary flags) so the framed shot stays clean. const isCaptureMode = useEditor((s) => s.isCaptureMode) - const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode + const noEditing = + isVersionPreviewMode || isFirstPersonMode || isXRMode || isStudioMode || isCaptureMode return ( <> - {!(isFirstPersonMode || isStudioMode || isCaptureMode) && } + {!noEditing && } {!noEditing && } {!noEditing && } {!noEditing && } @@ -800,21 +812,21 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } - {!isFirstPersonMode && } + {!(isFirstPersonMode || isXRMode) && } {isFirstPersonMode ? : } - + {!noEditing && } {!noEditing && } - - - {!(isLoading || isFirstPersonMode) && } + {!noEditing && } + {!noEditing && } + {!(isLoading || isFirstPersonMode || isXRMode) && } {!(isLoading || noEditing) && } {isFirstPersonMode && } - {isCaptureMode && } - - - {!isFirstPersonMode && } + {isCaptureMode && !isXRMode && } + {!isXRMode && } + {!isXRMode && } + {!(isFirstPersonMode || isXRMode) && } {!noEditing && viewerSceneSlot} @@ -1002,6 +1014,9 @@ const ViewerCanvas = memo(function ViewerCanvas({ viewerSceneSlot, floorplanSceneSlot, disablePostFx = false, + forceWebGL = false, + xr, + immersivePresentation = false, }: { isVersionPreviewMode: boolean isLoading: boolean @@ -1015,6 +1030,9 @@ const ViewerCanvas = memo(function ViewerCanvas({ viewerSceneSlot?: ReactNode floorplanSceneSlot?: ReactNode disablePostFx?: boolean + forceWebGL?: boolean + xr?: ViewerXRConfig + immersivePresentation?: boolean }) { const viewMode = useEditor((s) => s.viewMode) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) @@ -1093,7 +1111,10 @@ const ViewerCanvas = memo(function ViewerCanvas({ }} >
- +
{viewMode === 'split' && (
- {!(showLoader || isVersionPreviewMode) && } + {!(showLoader || isVersionPreviewMode || immersivePresentation) && } ) }) @@ -1234,6 +1258,9 @@ function EditorContent({ onLoaderChange, onThumbnailCapture, disablePostFx = false, + forceWebGL = false, + xr, + immersivePresentation = false, sidebarOverlay, viewerBanner, settingsPanelProps, @@ -1470,6 +1497,7 @@ function EditorContent({ const viewerCanvas = ( ) @@ -1556,6 +1586,7 @@ function EditorContent({ ) : ( <> diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 80568c44ee..0676f3ef42 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -49,6 +49,7 @@ import { import { isHistoryShortcut } from '../../lib/history' import { endpointReshapeScope } from '../../lib/interaction/scope' import { sfxEmitter } from '../../lib/sfx-bus' +import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, @@ -769,7 +770,9 @@ function WallBaseElevationHandle({ const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( levelObject.matrixWorld, ) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + const planeNormal = new Vector3() + .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld) + .setY(0) if (planeNormal.lengthSq() === 0) return null planeNormal.normalize() const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) @@ -912,12 +915,17 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { // the camera (projected to horizontal). Raycasting against it converts // pointer movement into a world-space Y value. const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + const planeNormal = new Vector3() + .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld) + .setY(0) if (planeNormal.lengthSq() === 0) return planeNormal.normalize() const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) const ndc = new Vector2() + const spatialPointerId = getSpatialPointerId(event.nativeEvent) + const spatialPointerSource = typeof spatialPointerId === 'object' ? spatialPointerId : null + const spatialRay = spatialPointerSource ? event.ray.clone() : null const setNDC = (clientX: number, clientY: number) => { const rect = gl.domElement.getBoundingClientRect() ndc.set( @@ -926,10 +934,12 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { ) } - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) + if (!spatialRay) { + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + } const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return + if (!(spatialRay ?? raycaster.ray).intersectPlane(plane, hit)) return // Dragging the top makes the wall custom-height; seed from the resolved // effective height so a plane-bound wall's drag starts at its real top. @@ -937,6 +947,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight + let releaseSpatialCapture: (() => void) | null = null document.body.style.cursor = 'ns-resize' sfxEmitter.emit('sfx:item-pick') @@ -954,8 +965,11 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const onMove = (e: PointerEvent) => { setNDC(e.clientX, e.clientY) raycaster.setFromCamera(ndc, camera) + applyRay(raycaster.ray) + } + const applyRay = (ray: Ray) => { const intersection = new Vector3() - if (!raycaster.ray.intersectPlane(plane, intersection)) return + if (!ray.intersectPlane(plane, intersection)) return const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) pendingHeight = newHeight useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) @@ -967,6 +981,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) + releaseSpatialCapture?.() + releaseSpatialCapture = null if (document.body.style.cursor === 'ns-resize') { document.body.style.cursor = '' } @@ -1007,6 +1023,16 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { } dragCleanupRef.current = cleanup + if (spatialPointerSource && spatialRay) { + releaseSpatialCapture = spatialPointerInput.capture(spatialPointerSource, { + onMove: (ray) => { + spatialRay.copy(ray) + applyRay(spatialRay) + }, + onRelease: onUp, + onCancel, + }) + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index b1bc2132ed..43f3f07dc8 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -15,6 +15,7 @@ import { Icon } from '@iconify/react' import { Move, Trash2 } from 'lucide-react' import { type ComponentType, lazy, Suspense, useCallback } from 'react' import { resolveMoveActionNode } from '../../../lib/direct-manipulation' +import { commitParametricNodeFields } from '../../../lib/parametric-node-update' import { sfxEmitter } from '../../../lib/sfx-bus' import { collectZoneContentIds } from '../../../lib/zone-content' import useEditor from '../../../store/use-editor' @@ -61,22 +62,9 @@ export function ParametricInspector({ const handleUpdate = useCallback( (patch: Partial) => { if (!selectedId) return - const scene = useScene.getState() - const node = scene.nodes[selectedId] - if (parametrics?.derive && node) { - const next = { ...node, ...patch } as AnyNode - patch = { ...patch, ...parametrics.derive(next, patch, node as AnyNode) } - } - // Bundle the edited node + any reconcile follow-ups into ONE - // updateNodes call so a single inspector edit is a single undo step. - const updates: { id: AnyNodeId; data: Partial }[] = [{ id: selectedId, data: patch }] - if (parametrics?.reconcile && node) { - const next = { ...node, ...patch } as AnyNode - updates.push(...parametrics.reconcile(node as AnyNode, next)) - } - scene.updateNodes(updates) + commitParametricNodeFields(selectedId, patch) }, - [selectedId, parametrics], + [selectedId], ) const clearSelection = useCallback(() => { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index e1ff9154a0..64357b79a2 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -133,6 +133,19 @@ const exitToSelectAfterUnconsumedCancel = () => { useEditor.getState().setSelectedReferenceId(null) } +// Cancel the active editor action with the same consume-or-exit semantics as +// Escape. Spatial inputs use this instead of unconditionally selecting the +// Select tool, so multi-step tools can keep their tool active after clearing +// the current draft. +export const cancelActiveTool = () => { + _toolCancelConsumed = false + emitter.emit('tool:cancel') + if (!_toolCancelConsumed) { + exitToSelectAfterUnconsumedCancel() + } + return _toolCancelConsumed +} + // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…) // reads as "abort this action", not history undo — behave exactly like Escape // and report whether anything was in flight so the undo/redo arms know to @@ -359,14 +372,9 @@ export const useKeyboard = ({ return } - _toolCancelConsumed = false - emitter.emit('tool:cancel') - // Only switch to select mode if no tool had an active mid-action to cancel. // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool) - if (!_toolCancelConsumed) { - exitToSelectAfterUnconsumedCancel() - } + cancelActiveTool() } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('site') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 0326dfa60f..8079011b73 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -49,7 +49,7 @@ export { FloatingActionMenu as FloatingMenu } from './components/editor/floating // camera controls via the `useViewer.inputDragging` / `useEditor.movingNode` // flags. Tools place onto `useViewer.selection.levelId`, so the host must set a // building + level selection first. -export { Grid } from './components/editor/grid' +export { EDITOR_GRID_INPUT_NAME, Grid } from './components/editor/grid' export { DimensionPill, type DimensionPillPart, @@ -79,10 +79,12 @@ export { useInvisibleHitAreaMaterial, } from './components/editor/node-arrow-handles' export { QuickMeasurementCard } from './components/editor/quick-measurement-card' +export { SelectionManager } from './components/editor/selection-manager' export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +export { WallMoveSideHandles } from './components/editor/wall-move-side-handles' export { useFloorplanRender } from './components/editor-2d/floorplan-render-context' export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer' export { FloorplanGeometryRenderer } from './components/editor-2d/renderers/floorplan-geometry-renderer' @@ -328,7 +330,7 @@ export type { SaveStatus } from './hooks/use-auto-save' // can express their affordances declaratively in their own folder. export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). -export { markToolCancelConsumed } from './hooks/use-keyboard' +export { cancelActiveTool, markToolCancelConsumed } from './hooks/use-keyboard' export { useReducedMotion } from './hooks/use-reduced-motion' export { type Selection, useSelection } from './hooks/use-selection' export { @@ -359,6 +361,7 @@ export { continuationContextOf, nextContinuation, } from './lib/continuation' +export { canDirectMoveNode } from './lib/direct-manipulation' export { createEditorApi } from './lib/editor-api' export { clearStructuralElevationGuide, @@ -511,6 +514,13 @@ export { metersToLinearUnit, squareMetersToAreaUnit, } from './lib/measurements' +export { + cyclePaintScope, + type PaintHoverInfo, + type PaintScope, + paintScopeLabel, +} from './lib/paint-scope' +export { commitParametricNodeFields } from './lib/parametric-node-update' export { consumePlacementDragRelease } from './lib/placement-drag-release' export { addFreshPlacementMetadata, @@ -542,7 +552,7 @@ export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from '. export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { movementSfxStepKey } from './lib/sfx/movement-tick' -export { triggerSFX } from './lib/sfx-bus' +export { emitDeleteSFX, triggerSFX } from './lib/sfx-bus' export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player' export { clearSlabSnapFeedback, @@ -555,12 +565,14 @@ export { type SlabPlanSnapResult, } from './lib/slab-plan-snap' export { + cycleSnappingModeIn, getSnappingModeLabel, resolveSnapFlags, type SnapContext, type SnapFlags, type SnappingMode, } from './lib/snapping-mode' +export { getSpatialPointerId, spatialPointerInput } from './lib/spatial-pointer-input' export { duplicateStairSubtree } from './lib/stair-duplication' export { getBuildingLevelsForLevel, @@ -578,11 +590,15 @@ export { type SurfacePlanSnapResult, } from './lib/surface-plan-snap' export { + brushRadiusRange, + clipTerrainPatchToSite, + commitStroke, fieldExtentForSite, flattenSite, resetSiteTerrain, resolveFlattenTarget, sculptFieldForSite, + terrainPointInsideSite, } from './lib/terrain-sculpt' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // nodes` so they don't need their own copy / their own tailwind-merge @@ -631,6 +647,7 @@ export { isAngleSnapActive, isGridSnapActive, isMagneticSnapActive, + selectDefaultBuildingAndLevel, } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' diff --git a/packages/editor/src/lib/parametric-node-update.ts b/packages/editor/src/lib/parametric-node-update.ts new file mode 100644 index 0000000000..814ea15be6 --- /dev/null +++ b/packages/editor/src/lib/parametric-node-update.ts @@ -0,0 +1,37 @@ +import { + type AnyNode, + type AnyNodeId, + nodeRegistry, + type ParametricDescriptor, + useScene, +} from '@pascal-app/core' + +export function commitParametricNodeFields( + nodeId: AnyNodeId, + requestedPatch: Partial, +): void { + const scene = useScene.getState() + const node = scene.nodes[nodeId] + if (!node) return + + const parametrics = nodeRegistry.get(node.type)?.parametrics as + | ParametricDescriptor + | undefined + let patch = requestedPatch as Record + if (parametrics?.derive) { + const next = { ...node, ...patch } as AnyNode + patch = { + ...patch, + ...parametrics.derive(next, patch as Partial, node), + } + } + + const updates: { id: AnyNodeId; data: Partial }[] = [ + { id: nodeId, data: patch as Partial }, + ] + if (parametrics?.reconcile) { + const next = { ...node, ...patch } as AnyNode + updates.push(...parametrics.reconcile(node, next)) + } + scene.updateNodes(updates) +} diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts new file mode 100644 index 0000000000..9f06d85738 --- /dev/null +++ b/packages/editor/src/lib/scene.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry, registerNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { z } from 'zod' +import useEditor from '../store/use-editor' +import { normalizeSceneGraphNodes, syncEditorSelectionFromCurrentScene } from './scene' + +const building = { + children: ['level_scene-root'], + id: 'building_scene-root', + object: 'node', + parentId: null, + position: [0, 0, 0], + rotation: [0, 0, 0], + type: 'building', + visible: true, +} + +const level = { + children: ['wall_scene-root'], + id: 'level_scene-root', + level: 0, + object: 'node', + parentId: building.id, + type: 'level', + visible: true, +} + +const wall = { + children: [], + end: [4, 0], + id: 'wall_scene-root', + object: 'node', + parentId: level.id, + start: [0, 0], + type: 'wall', + visible: true, +} + +describe('scene selection synchronization', () => { + beforeEach(() => { + useViewer.getState().resetSelection() + useEditor.setState({ mode: 'select', phase: 'site', tool: null }) + }) + + test('enters the first level when a scene graph is rooted at a building', () => { + useScene.setState({ + nodes: { + [building.id]: building, + [level.id]: level, + [wall.id]: wall, + }, + rootNodeIds: [building.id], + } as never) + + syncEditorSelectionFromCurrentScene() + + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: level.id, + }) + expect(useEditor.getState().phase).toBe('structure') + }) +}) + +describe('scene graph normalization', () => { + test('materializes registered schema defaults before the graph reaches renderers', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + registerNode({ + kind: 'test-scene-normalization', + schema: z.object({ + id: z.string(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + type: z.literal('test-scene-normalization'), + }), + schemaVersion: 1, + } as never) + + expect( + normalizeSceneGraphNodes({ + test: { id: 'test', type: 'test-scene-normalization' }, + unknown: { id: 'unknown', type: 'unknown-kind', custom: true }, + }), + ).toEqual({ + test: { + id: 'test', + position: [0, 0, 0], + rotation: [0, 0, 0], + type: 'test-scene-normalization', + }, + unknown: { id: 'unknown', type: 'unknown-kind', custom: true }, + }) + } finally { + restoreRegistry() + } + }) +}) diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 52bc3795e9..45c3358b76 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -276,9 +276,13 @@ function getRestoredSelectionForScene( export function syncEditorSelectionFromCurrentScene() { const sceneNodes = useScene.getState().nodes as Record const sceneRootIds = useScene.getState().rootNodeIds - const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child) - const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') + const rootNodes = sceneRootIds.map((id) => sceneNodes[id]).filter(Boolean) + const firstBuilding = + rootNodes.find((node) => node.type === 'building') ?? + rootNodes + .flatMap((node) => (Array.isArray(node.children) ? node.children.map(resolve) : [])) + .find((node) => node?.type === 'building') const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState()) const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState) @@ -396,11 +400,25 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } +export function normalizeSceneGraphNodes( + nodes: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(nodes).map(([id, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [id, value] + const type = (value as { type?: unknown }).type + if (typeof type !== 'string') return [id, value] + const parsed = nodeRegistry.get(type)?.schema.safeParse(value) + return [id, parsed?.success ? parsed.data : value] + }), + ) +} + export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() if (hasUsableSceneGraph(sceneGraph)) { const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph - useScene.getState().setScene(nodes as any, rootNodeIds as any, { + useScene.getState().setScene(normalizeSceneGraphNodes(nodes) as any, rootNodeIds as any, { collections: collections as any, materials: materials as any, installedPlugins: installedPlugins ?? defaultInstalledPlugins, diff --git a/packages/editor/src/lib/spatial-pointer-input.test.ts b/packages/editor/src/lib/spatial-pointer-input.test.ts new file mode 100644 index 0000000000..30f9d170bb --- /dev/null +++ b/packages/editor/src/lib/spatial-pointer-input.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { Ray, Vector3 } from 'three' +import { getSpatialPointerId, SpatialPointerInput } from './spatial-pointer-input' + +describe('SpatialPointerInput', () => { + test('recognizes current and legacy XR native event shapes', () => { + const source = {} + expect(getSpatialPointerId({ inputSource: source })).toBe(source) + expect(getSpatialPointerId({ pointerState: { inputSource: source } })).toBe(source) + expect(getSpatialPointerId({ pointerType: 'mouse' })).toBeNull() + }) + + test('keeps move and release bound to the pointer that captured a handle', () => { + const input = new SpatialPointerInput() + const moves: Ray[] = [] + let releases = 0 + + input.capture(42, { + onMove: (ray) => moves.push(ray.clone()), + onRelease: () => releases++, + onCancel: () => undefined, + }) + + const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0)) + expect(input.move(7, ray)).toBe(false) + expect(input.move(42, ray)).toBe(true) + expect(input.release(7)).toBe(false) + expect(input.release(42)).toBe(true) + expect(moves).toHaveLength(1) + expect(moves[0]?.origin.toArray()).toEqual([1, 2, 3]) + expect(releases).toBe(1) + expect(input.move(42, ray)).toBe(false) + }) + + test('cancels a captured handle without releasing it', () => { + const input = new SpatialPointerInput() + let cancels = 0 + let releases = 0 + + input.capture(9, { + onMove: () => undefined, + onRelease: () => releases++, + onCancel: () => cancels++, + }) + + expect(input.cancel(9)).toBe(true) + expect(cancels).toBe(1) + expect(releases).toBe(0) + }) +}) diff --git a/packages/editor/src/lib/spatial-pointer-input.ts b/packages/editor/src/lib/spatial-pointer-input.ts new file mode 100644 index 0000000000..17c8e62858 --- /dev/null +++ b/packages/editor/src/lib/spatial-pointer-input.ts @@ -0,0 +1,56 @@ +import type { Ray } from 'three' + +type SpatialPointerCapture = { + onMove: (ray: Ray) => void + onRelease: () => void + onCancel: () => void +} + +export type SpatialPointerId = object | number | string + +export function getSpatialPointerId(nativeEvent: unknown): SpatialPointerId | null { + if (!nativeEvent || typeof nativeEvent !== 'object') return null + const event = nativeEvent as { + inputSource?: object + pointerState?: { inputSource?: object } + } + return event.inputSource ?? event.pointerState?.inputSource ?? null +} + +export class SpatialPointerInput { + private readonly captures = new Map() + + capture(pointerId: SpatialPointerId, capture: SpatialPointerCapture): () => void { + this.captures.set(pointerId, capture) + return () => { + if (this.captures.get(pointerId) === capture) { + this.captures.delete(pointerId) + } + } + } + + move(pointerId: SpatialPointerId, ray: Ray): boolean { + const capture = this.captures.get(pointerId) + if (!capture) return false + capture.onMove(ray) + return true + } + + release(pointerId: SpatialPointerId): boolean { + const capture = this.captures.get(pointerId) + if (!capture) return false + this.captures.delete(pointerId) + capture.onRelease() + return true + } + + cancel(pointerId: SpatialPointerId): boolean { + const capture = this.captures.get(pointerId) + if (!capture) return false + this.captures.delete(pointerId) + capture.onCancel() + return true + } +} + +export const spatialPointerInput = new SpatialPointerInput() diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 4097c35b9a..b97ce99a05 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -31,6 +31,7 @@ "three": "^0.185" }, "dependencies": { + "@react-three/xr": "^6.6.30", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5" diff --git a/packages/viewer/src/components/viewer/frame-limiter.tsx b/packages/viewer/src/components/viewer/frame-limiter.tsx index ccd1859939..f143ac1fed 100644 --- a/packages/viewer/src/components/viewer/frame-limiter.tsx +++ b/packages/viewer/src/components/viewer/frame-limiter.tsx @@ -83,6 +83,11 @@ const FrameLimiter: React.FC = ({ fps = 50, paused = false }) } function tick(t: DOMHighResTimeStamp) { raf = requestAnimationFrame(tick) + // While an immersive XR session is presenting, the XR session's + // requestAnimationFrame loop owns rendering. A window RAF here can + // render with no XRFrame and overwrite the XR framebuffer between + // headset frames. + if (renderer.xr?.isPresenting) return syncSize() const frameTime = clock.sample(t, interval) if (frameTime === null) return @@ -90,6 +95,7 @@ const FrameLimiter: React.FC = ({ fps = 50, paused = false }) timeSpan('frame-cpu', () => advance(frameTime)) } function kick() { + if (renderer.xr?.isPresenting) return syncSize() const frameTime = clock.step(1 / 1000) nextFrameTimeRef.current = frameTime diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index b6c95c1c7a..58ebdfeec3 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -9,6 +9,7 @@ import { } from '@pascal-app/core' import { Canvas, extend, type ThreeElement, useFrame, useThree } from '@react-three/fiber' import { + type ComponentType, forwardRef, useEffect, useImperativeHandle, @@ -29,6 +30,13 @@ import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' +import { shouldMountPostProcessingRenderDriver } from '../../xr/frame-loop' +import { GOD_ORIGIN_POSITION } from '../../xr/god-mode' +import { PlayerModeScene } from '../../xr/mode-switching' +import { immersiveXRBackgroundColor } from '../../xr/presentation-background' +import { ImmersiveXRPresentationProvider } from '../../xr/presentation-context' +import { ViewerXRSessionRoot } from '../../xr/session-root' +import type { ViewerXRStore } from '../../xr/store' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' import { BATCH_SPIKE_ENABLED, BatchedMeshSpike } from './batched-mesh-spike' @@ -167,7 +175,7 @@ type WebGPUDeviceLike = { removeEventListener?: (type: string, listener: EventListener) => void } -function GPUDeviceWatcher() { +function GPUDeviceWatcher({ intentionalWebGL = false }: { intentionalWebGL?: boolean }) { const gl = useThree((s) => s.gl) useEffect(() => { @@ -180,10 +188,12 @@ function GPUDeviceWatcher() { const device = backend?.device as WebGPUDeviceLike | undefined if (!device) { - console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { - backend: backend?.constructor?.name ?? 'unknown', - rendererType: (gl as any).constructor?.name ?? 'unknown', - }) + if (!intentionalWebGL) { + console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { + backend: backend?.constructor?.name ?? 'unknown', + rendererType: (gl as any).constructor?.name ?? 'unknown', + }) + } return } @@ -209,7 +219,7 @@ function GPUDeviceWatcher() { return () => { device.removeEventListener?.('uncapturederror', onUncapturedError) } - }, [gl]) + }, [gl, intentionalWebGL]) return null } @@ -227,6 +237,11 @@ function ToneMappingExposure() { return null } +function ImmersiveXRBackground() { + const background = useViewer((state) => immersiveXRBackgroundColor(state.sceneTheme)) + return +} + function hasPendingSceneBuildWork() { const { dirtyNodes, nodes, rootNodeIds } = useScene.getState() @@ -321,6 +336,15 @@ function SceneReadyTracker({ return null } +export interface ViewerXRConfig { + store: ViewerXRStore + playerModes?: boolean + multiview?: boolean + originPosition?: [number, number, number] + session?: XRSession + inputSourceOverlay?: ComponentType<{ type: 'controller' | 'hand' }> +} + interface ViewerProps { children?: React.ReactNode hoverStyles?: HoverStyles @@ -381,6 +405,10 @@ interface ViewerProps { disablePostFx?: boolean /** Keep the mounted renderer/context warm without advancing scene frames. */ renderPaused?: boolean + /** Mount the viewer in immersive WebXR mode using a WebGL renderer. */ + xr?: ViewerXRConfig + /** Force the WebGL backend for non-XR consumers that require it. */ + forceWebGL?: boolean } /** Imperative handle exposed via `ref` on ``. */ @@ -411,6 +439,8 @@ const Viewer = forwardRef(function Viewer( maxFps = 50, disablePostFx = false, renderPaused = false, + xr, + forceWebGL = false, }, ref, ) { @@ -514,6 +544,15 @@ const Viewer = forwardRef(function Viewer( if (showGpuFallback) onSceneReadyChange?.(true) }, [showGpuFallback, onSceneReadyChange]) + useEffect(() => { + if (!xr?.session) return + + // An already-active immersive session can suppress the initial observer + // notification when the WebGL canvas replaces the desktop WebGPU canvas. + const timeout = window.setTimeout(() => window.dispatchEvent(new Event('resize')), 0) + return () => window.clearTimeout(timeout) + }, [xr?.session]) + if (showGpuFallback) { return } @@ -532,10 +571,12 @@ const Viewer = forwardRef(function Viewer( gl={ ((props: { canvas?: HTMLCanvasElement; powerPreference?: RendererPowerPreference }) => { const canvas = props.canvas + const xrMultiview = xr?.multiview ?? false const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined if (cached) return cached const promise = (async () => { const result = await initializeGpuRenderer({ + forceWebGL: xr != null || forceWebGL, // Supplying `device` makes three skip its own `requestAdapter`, // so R3F's `powerPreference` only reaches the GPU if we forward it. powerPreference: props.powerPreference, @@ -544,10 +585,10 @@ const Viewer = forwardRef(function Viewer( ...(props as any), ...backendParameters, alpha: true, + multiview: xrMultiview, // Allocates the backend's timestamp query pool so // `resolveTimestampsAsync()` can report real GPU render-pass - // time (post-processing.tsx). The backend self-disables it - // when the device lacks 'timestamp-query'. + // time. The WebGL XR backend ignores this WebGPU-only option. trackTimestamp: PERF_OVERLAY_ENABLED, }) renderer.toneMapping = THREE.ACESFilmicToneMapping @@ -558,6 +599,9 @@ const Viewer = forwardRef(function Viewer( }, }) if (result.status === 'ready') { + // XR uses the same WebGL-backed WebGPURenderer as the editor's + // desktop fallback. Empty transient geometries are unsafe in + // both paths because they submit a draw with no position buffer. installEmptyDrawGuard(result.renderer) return result.renderer } @@ -585,60 +629,152 @@ const Viewer = forwardRef(function Viewer( enabled: shadowsEnabled, }} > - - - - - - - - - {/* */} - - {useBvh ? ( - - - + + {xr ? ( + + + {children} + + ) : ( - + <> + + + {children} + + )} - - {/* Generic slab-elevation lift for any kind that declares - `capabilities.floorPlaced`. Runs at frame priority 1 so it - lands its mesh.position.y override before the priority-2 - systems below clear the dirty mark. */} - - {/* Generic geometry rebuild loop for any registered kind that - ships `def.geometry`. Reads dirtyNodes, calls the kind's pure - builder, swaps the registered group's children. See - wiki/architecture/node-definitions.md. */} - - {/* Automated stair opening sync — updates slab/ceiling cutouts - whenever stairs, slabs, or levels change. */} - - {/* Mounts systems contributed by registry-backed kinds. Each - kind's `def.system` is loaded via lazy() and rendered here, - ordered by `system.priority`. */} - - - {selectionManager === 'default' && } - {(perf || PERF_OVERLAY_ENABLED) && } - {/* Feeds the action-cost ledger the frame's settle state (dirty - queue + deferred wall rebuilds) at a priority after every other - system, so a receipt closes when the user can actually see the - edit. */} - {(perf || PERF_OVERLAY_ENABLED) && } - {BATCH_SPIKE_ENABLED && } - {children} - + ) }) +function ViewerScene({ + children, + disablePostFx, + inputSourceOverlay, + playerModes = false, + hoverStyles, + immersiveXR = false, + onSceneReadyChange, + perf, + sceneReadyKey, + sceneReadyMaxWaitMs, + selectionManager, + useBvh, + xrStore, +}: { + children?: React.ReactNode + disablePostFx: boolean + inputSourceOverlay?: ComponentType<{ type: 'controller' | 'hand' }> + playerModes?: boolean + hoverStyles: HoverStyles + immersiveXR?: boolean + onSceneReadyChange?: (ready: boolean) => void + perf: boolean + sceneReadyKey?: string | number | null + sceneReadyMaxWaitMs?: number + selectionManager: 'default' | 'custom' + useBvh: boolean + xrStore?: ViewerXRStore +}) { + const renderedScene = useBvh ? ( + + + + ) : ( + + ) + + const spatialScene = ( + <> + {renderedScene} + + {/* Generic slab-elevation lift for any kind that declares + `capabilities.floorPlaced`. Runs at frame priority 1 so it + lands its mesh.position.y override before the priority-2 + systems below clear the dirty mark. */} + + {/* Generic geometry rebuild loop for any registered kind that + ships `def.geometry`. Reads dirtyNodes, calls the kind's pure + builder, swaps the registered group's children. See + wiki/architecture/node-definitions.md. */} + + {/* Automated stair opening sync — updates slab/ceiling cutouts + whenever stairs, slabs, or levels change. */} + + {/* Mounts systems contributed by registry-backed kinds. Each + kind's `def.system` is loaded via lazy() and rendered here, + ordered by `system.priority`. */} + + {children} + + ) + + return ( + <> + + {immersiveXR && } + + + + + + {/* */} + + {playerModes && xrStore ? ( + + {spatialScene} + + ) : ( + spatialScene + )} + {shouldMountPostProcessingRenderDriver(immersiveXR) && ( + + )} + {selectionManager === 'default' && } + {(perf || PERF_OVERLAY_ENABLED) && } + {/* Feeds the action-cost ledger the frame's settle state after all + scene systems so a receipt closes when the edit is visible. */} + {(perf || PERF_OVERLAY_ENABLED) && } + {BATCH_SPIKE_ENABLED && } + + + ) +} + export default Viewer diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index e2c379cae8..b6fb8fa263 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -688,6 +688,10 @@ const PostProcessingPasses = ({ ]) useFrame((_, delta) => { + // The session binding renders with Three's stereo XR camera. Rendering + // this desktop-camera pass during the same frame clears that framebuffer. + if (renderer.xr?.isPresenting) return + if (size.width < 1 || size.height < 1) { return } diff --git a/packages/viewer/src/components/viewer/viewer-camera.test.ts b/packages/viewer/src/components/viewer/viewer-camera.test.ts new file mode 100644 index 0000000000..535c0d1a60 --- /dev/null +++ b/packages/viewer/src/components/viewer/viewer-camera.test.ts @@ -0,0 +1,57 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { Layers } from 'three' +import { + applyViewerCameraClipping, + enableImmersiveXRViewLayers, + viewerCameraClipping, + viewerUsesPerspectiveCamera, +} from './viewer-camera' + +describe('viewerCameraClipping', () => { + test('uses the WebXR Home clipping range for immersive presentation', () => { + expect(viewerCameraClipping(true)).toEqual({ far: 10_000, near: 0.001 }) + }) + + test('keeps the existing desktop clipping range', () => { + expect(viewerCameraClipping(false)).toEqual({ far: 1000, near: 0.1 }) + }) + + test('XR always uses a perspective camera', () => { + expect(viewerUsesPerspectiveCamera('orthographic', true)).toBe(true) + expect(viewerUsesPerspectiveCamera('perspective', true)).toBe(true) + expect(viewerUsesPerspectiveCamera('orthographic', false)).toBe(false) + }) + + test('XR clipping can be applied to Three’s session camera', () => { + let projectionUpdates = 0 + const camera = { + far: 2000, + near: 0.1, + updateProjectionMatrix: () => { + projectionUpdates += 1 + }, + } + + applyViewerCameraClipping(camera, true) + + expect(camera).toMatchObject({ far: 10_000, near: 0.001 }) + expect(projectionUpdates).toBe(1) + }) + + test('XR enables presentation layers and restores the prior masks', () => { + const cameraLayers = new Layers() + const raycasterLayers = new Layers() + const cameraMask = cameraLayers.mask + const raycasterMask = raycasterLayers.mask + + const restore = enableImmersiveXRViewLayers(cameraLayers, raycasterLayers) + + expect(cameraLayers.mask).not.toBe(cameraMask) + expect(raycasterLayers.mask).not.toBe(raycasterMask) + restore() + expect(cameraLayers.mask).toBe(cameraMask) + expect(raycasterLayers.mask).toBe(raycasterMask) + }) +}) diff --git a/packages/viewer/src/components/viewer/viewer-camera.tsx b/packages/viewer/src/components/viewer/viewer-camera.tsx index adb24e9ff3..ed999fb5e9 100644 --- a/packages/viewer/src/components/viewer/viewer-camera.tsx +++ b/packages/viewer/src/components/viewer/viewer-camera.tsx @@ -1,12 +1,73 @@ import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei' +import { useThree } from '@react-three/fiber' +import { useEffect } from 'react' +import type { Layers } from 'three' +import { GRID_LAYER, OVERLAY_LAYER, ZONE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' -export const ViewerCamera = () => { +const IMMERSIVE_XR_VISIBLE_LAYERS = [OVERLAY_LAYER, ZONE_LAYER, GRID_LAYER] as const + +export function enableImmersiveXRViewLayers(cameraLayers: Layers, raycasterLayers: Layers) { + const cameraMask = cameraLayers.mask + const raycasterMask = raycasterLayers.mask + for (const layer of IMMERSIVE_XR_VISIBLE_LAYERS) { + cameraLayers.enable(layer) + raycasterLayers.enable(layer) + } + return () => { + cameraLayers.mask = cameraMask + raycasterLayers.mask = raycasterMask + } +} + +function ImmersiveXRViewLayers({ enabled }: { enabled: boolean }) { + const camera = useThree((state) => state.camera) + const raycaster = useThree((state) => state.raycaster) + + useEffect(() => { + if (!enabled) return + return enableImmersiveXRViewLayers(camera.layers, raycaster.layers) + }, [camera, enabled, raycaster]) + + return null +} + +export function viewerCameraClipping(immersiveXR: boolean) { + return immersiveXR ? { far: 10_000, near: 0.001 } : { far: 1000, near: 0.1 } +} + +export function applyViewerCameraClipping( + camera: { far: number; near: number; updateProjectionMatrix(): void }, + immersiveXR: boolean, +) { + const clipping = viewerCameraClipping(immersiveXR) + camera.far = clipping.far + camera.near = clipping.near + camera.updateProjectionMatrix() +} + +export function viewerUsesPerspectiveCamera(cameraMode: string, immersiveXR: boolean) { + return immersiveXR || cameraMode === 'perspective' +} + +export const ViewerCamera = ({ immersiveXR = false }: { immersiveXR?: boolean }) => { const cameraMode = useViewer((state) => state.cameraMode) + const clipping = viewerCameraClipping(immersiveXR) - return cameraMode === 'perspective' ? ( - - ) : ( - + return ( + <> + {viewerUsesPerspectiveCamera(cameraMode, immersiveXR) ? ( + + ) : ( + + )} + + ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 6b57ca7029..3c99d263ec 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -12,7 +12,11 @@ export { ErrorBoundary } from './components/error-boundary' // `@pascal-app/nodes//renderer.tsx` and are loaded by the registry // — no per-kind re-exports needed. export { NodeRenderer } from './components/renderers/node-renderer' -export { default as Viewer, type ViewerHandle } from './components/viewer' +export { + default as Viewer, + type ViewerHandle, + type ViewerXRConfig, +} from './components/viewer' export { type BVHEcctrlApi, default as BVHEcctrl, @@ -271,3 +275,13 @@ export { } from './systems/window/window-animation-system' export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system' export { ZoneSystem } from './systems/zone/zone-system' +export { requestGodScaleReset, useGodScaleView } from './xr/god-mode' +export { + toggleXRPlayerMode, + useXRPlayerMode, + XR_PLAYER_MODES, + type XRPlayerMode, +} from './xr/mode-switching' +export { useImmersiveXRPresentation } from './xr/presentation-context' +export { createViewerXRStore, type ViewerXRStore } from './xr/store' +export { getImmersiveVRSupport, type ImmersiveVRSupport } from './xr/support' diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx index b52e6fcaa5..8be9d9f6d8 100644 --- a/packages/viewer/src/lib/renderer-capability.test.tsx +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -14,6 +14,23 @@ function canvasWithContexts(contexts: Partial>) { } describe('GPU renderer capability and initialization', () => { + test('forces WebGL without requesting a WebGPU adapter', async () => { + const requestAdapter = mock(async () => ({ requestDevice: async () => ({}) })) + const createRenderer = mock(() => ({ init: async () => undefined })) + + const result = await initializeGpuRenderer({ + createRenderer, + forceWebGL: true, + gpu: { requestAdapter }, + probeCanvas: canvasWithContexts({ webgl2: {} }), + }) + + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + expect(requestAdapter).not.toHaveBeenCalled() + expect(createRenderer).toHaveBeenCalledWith({ forceWebGL: true }) + }) + test('uses a working WebGPU device without requiring WebGL', async () => { const device = {} const createRenderer = mock(() => ({ init: async () => undefined })) diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index a528fda857..548ac62e34 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -121,12 +121,14 @@ export async function detectRendererCapability({ export async function initializeGpuRenderer({ createRenderer, + forceWebGL = false, gpu, powerPreference, probeCanvas = browserCanvas(), webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { createRenderer: (parameters: RendererBackendParameters) => Renderer + forceWebGL?: boolean gpu?: RendererGpu | null powerPreference?: RendererPowerPreference probeCanvas?: RendererCapabilityCanvas | null @@ -134,7 +136,7 @@ export async function initializeGpuRenderer> { const capability = await detectRendererCapability({ canvas: probeCanvas, - gpu, + gpu: forceWebGL ? null : gpu, powerPreference, webgpuTimeoutMs, }) diff --git a/packages/viewer/src/xr/distance-aware-ray-pointer.tsx b/packages/viewer/src/xr/distance-aware-ray-pointer.tsx new file mode 100644 index 0000000000..b40283fe2c --- /dev/null +++ b/packages/viewer/src/xr/distance-aware-ray-pointer.tsx @@ -0,0 +1,162 @@ +'use client' + +import { createPortal, useFrame, useThree } from '@react-three/fiber' +import { + type DefaultXRInputSourceRayPointerOptions, + usePointerXRInputSourceEvents, + useRayPointer, + useXRInputSourceStateContext, + XRSpace, +} from '@react-three/xr' +import { useEffect, useMemo, useRef } from 'react' +import { type Layers, type Mesh, type Object3D, Quaternion, RingGeometry, Vector3 } from 'three' +import { BATCHED_LAYER, OVERLAY_LAYER, ZONE_LAYER } from '../lib/layers' +import { + POINTER_CURSOR_INNER_RADIUS, + POINTER_CURSOR_OUTER_RADIUS, + resolvePointerCursorSize, +} from './pointer-cursor' +import { PointerRingMaterial } from './pointer-ring-material' + +const NEAR_RAY_HIDE_DISTANCE = 0.2 +const Z_AXIS = new Vector3(0, 0, 1) +const ignoreRaycast = () => null + +type RayIntersectorWithLayers = { + raycaster?: { layers: Layers } +} + +export function DistanceAwareRayPointer({ + options, +}: { + options: DefaultXRInputSourceRayPointerOptions +}) { + const state = useXRInputSourceStateContext() + const space = useRef(null) + const rayModel = useRef(null) + const cursorModel = useRef(null) + const scene = useThree((current) => current.scene) + const cursorMaterial = useMemo(() => new PointerRingMaterial(), []) + const cursorGeometry = useMemo( + () => new RingGeometry(POINTER_CURSOR_INNER_RADIUS, POINTER_CURSOR_OUTER_RADIUS, 32), + [], + ) + const normalQuaternion = useRef(new Quaternion()) + const objectQuaternion = useRef(new Quaternion()) + const cursorOffset = useRef(new Vector3()) + const pointer = useRayPointer(space, state, { ...options, makeDefault: true }) + const rayModelOptions = typeof options.rayModel === 'object' ? options.rayModel : undefined + const cursorModelOptions = + typeof options.cursorModel === 'object' ? options.cursorModel : undefined + + usePointerXRInputSourceEvents(pointer, state.inputSource, 'select', state.events) + useEffect(() => () => cursorMaterial.dispose(), [cursorMaterial]) + useEffect(() => () => cursorGeometry.dispose(), [cursorGeometry]) + useEffect(() => { + const layers = (pointer.intersector as unknown as RayIntersectorWithLayers).raycaster?.layers + if (!layers) return + const mask = layers.mask + layers.enable(BATCHED_LAYER) + layers.enable(OVERLAY_LAYER) + layers.enable(ZONE_LAYER) + return () => { + layers.mask = mask + } + }, [pointer]) + + useFrame(() => { + const intersection = pointer.getIntersection() + const distance = intersection?.distance + if ( + !intersection || + distance == null || + !pointer.getEnabled() || + (intersection.object as Object3D & { isVoidObject?: boolean }).isVoidObject === true + ) { + if (rayModel.current) rayModel.current.visible = false + if (cursorModel.current) cursorModel.current.visible = false + return + } + + if (rayModel.current) { + rayModel.current.visible = distance >= NEAR_RAY_HIDE_DISTANCE + const rayLength = Math.min(rayModelOptions?.maxLength ?? distance, distance) + rayModel.current.position.z = -rayLength / 2 + const raySize = rayModelOptions?.size ?? 0.005 + rayModel.current.scale.set(raySize, raySize, rayLength) + } + + if (!cursorModel.current) return + cursorModel.current.visible = true + cursorModel.current.position.copy(intersection.pointOnFace) + const normal = intersection.normal ?? intersection.face?.normal + if (normal) { + normalQuaternion.current.setFromUnitVectors(Z_AXIS, normal) + intersection.object.getWorldQuaternion(objectQuaternion.current) + cursorModel.current.quaternion + .copy(objectQuaternion.current) + .multiply(normalQuaternion.current) + cursorOffset.current + .set(0, 0, cursorModelOptions?.cursorOffset ?? 0.008) + .applyQuaternion(cursorModel.current.quaternion) + cursorModel.current.position.add(cursorOffset.current) + } + cursorModel.current.scale.setScalar(resolvePointerCursorSize(distance)) + cursorModel.current.updateMatrix() + + if (cursorModelOptions) { + const color = + typeof cursorModelOptions.color === 'function' + ? cursorModelOptions.color(pointer) + : cursorModelOptions.color + if (Array.isArray(color)) cursorMaterial.color.set(...color) + else cursorMaterial.color.set(color ?? 'white') + cursorMaterial.opacity = + typeof cursorModelOptions.opacity === 'function' + ? cursorModelOptions.opacity(pointer) + : (cursorModelOptions.opacity ?? 0.4) + } + }) + + const rayColor = + typeof rayModelOptions?.color === 'function' + ? rayModelOptions.color(pointer) + : (rayModelOptions?.color ?? 'white') + + return ( + + {options.rayModel !== false && ( + + + + + )} + {createPortal( + + + + , + scene, + )} + + ) +} diff --git a/packages/viewer/src/xr/frame-loop.test.ts b/packages/viewer/src/xr/frame-loop.test.ts new file mode 100644 index 0000000000..167999af63 --- /dev/null +++ b/packages/viewer/src/xr/frame-loop.test.ts @@ -0,0 +1,192 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, mock, test } from 'bun:test' +import { + advanceXRFrameWithoutDesktopRender, + ownsXRFrameLoopBinding, + renderImmersiveXRFrame, + shouldMountPostProcessingRenderDriver, + shouldPauseFrameLimiterForXR, + stopXRFrameLoop, + takeOverXRFrameLoop, + unifyXRStereoCameraLayers, +} from './frame-loop' + +describe('takeOverXRFrameLoop', () => { + test('a superseded binding cannot restore over the current XR frame loop', () => { + const staleBinding = Symbol('stale') + const currentBinding = Symbol('current') + + expect(ownsXRFrameLoopBinding(currentBinding, staleBinding)).toBe(false) + expect(ownsXRFrameLoopBinding(currentBinding, currentBinding)).toBe(true) + }) + + test('advances R3F effects without issuing its desktop-camera render', () => { + const state = { internal: { priority: 0 } } + let priorityDuringAdvance = 0 + + advanceXRFrameWithoutDesktopRender(state, () => { + priorityDuringAdvance = state.internal.priority + }) + + expect(priorityDuringAdvance).toBe(1) + expect(state.internal.priority).toBe(0) + }) + + test('restores R3F render priority when frame advancement fails', () => { + const state = { internal: { priority: 2 } } + + expect(() => + advanceXRFrameWithoutDesktopRender(state, () => { + throw new Error('advance failed') + }), + ).toThrow('advance failed') + expect(state.internal.priority).toBe(2) + }) + + test('pauses the desktop frame loop as soon as an XR session is supplied', () => { + expect(shouldPauseFrameLimiterForXR(false, {} as XRSession)).toBe(true) + expect(shouldPauseFrameLimiterForXR(false, undefined)).toBe(false) + expect(shouldPauseFrameLimiterForXR(true, undefined)).toBe(true) + }) + + test('leaves XR rendering exclusively to Three’s XR animation loop', () => { + expect(shouldMountPostProcessingRenderDriver(true)).toBe(false) + expect(shouldMountPostProcessingRenderDriver(false)).toBe(true) + }) + + test('updates the stereo camera before drawing the immersive scene', () => { + const calls: string[] = [] + const xrOrigin = { type: 'xr-origin' } + const xrCamera = { parent: xrOrigin, type: 'xr-camera' } + const appCamera = { parent: null, type: 'app-camera' } + const renderer = { + render: mock((_scene: unknown, camera: unknown) => { + expect(camera).toBe(appCamera) + expect(appCamera.parent).toBeNull() + calls.push('render') + }), + xr: { + cameraAutoUpdate: true, + getCamera: mock(() => { + calls.push('get-camera') + return xrCamera + }), + updateCamera: mock(() => { + expect(appCamera.parent).toBe(xrOrigin) + calls.push('update-camera') + }), + }, + } + + renderImmersiveXRFrame(renderer, { type: 'scene' }, appCamera) + + expect(calls).toEqual(['get-camera', 'update-camera', 'render']) + expect(renderer.xr.cameraAutoUpdate).toBe(true) + }) + + test('restores the application camera parent when stereo updating fails', () => { + const appParent = { type: 'app-parent' } + const appCamera = { parent: appParent } + const renderer = { + render: mock(() => undefined), + xr: { + cameraAutoUpdate: true, + getCamera: mock(() => ({ parent: { type: 'xr-origin' } })), + updateCamera: mock(() => { + throw new Error('update failed') + }), + }, + } + + expect(() => renderImmersiveXRFrame(renderer, {}, appCamera)).toThrow('update failed') + expect(appCamera.parent).toBe(appParent) + }) + + test('restores automatic camera updates when an XR draw fails', () => { + const renderer = { + render: mock(() => { + throw new Error('draw failed') + }), + xr: { + cameraAutoUpdate: true, + getCamera: mock(() => ({ type: 'xr-camera' })), + updateCamera: mock(() => undefined), + }, + } + + expect(() => renderImmersiveXRFrame(renderer, {}, {})).toThrow('draw failed') + expect(renderer.xr.cameraAutoUpdate).toBe(true) + }) + + test('renders overlay and zone layers in both stereo eyes', () => { + const left = { layers: { mask: 0b1011 } } + const right = { layers: { mask: 0b1101 } } + const camera = { cameras: [left, right], layers: { mask: 0b1111 } } + + unifyXRStereoCameraLayers(camera) + + expect(left.layers.mask).toBe(0b1111) + expect(right.layers.mask).toBe(0b1111) + }) + + test('disconnects R3F and installs the renderer-owned XR frame loop', async () => { + const calls: string[] = [] + const setAnimationLoop = mock(async (callback: XRFrameRequestCallback | null) => { + calls.push(callback ? 'set-loop' : 'clear-loop') + }) + const renderer = { + setAnimationLoop, + setPixelRatio: mock((dpr: number) => calls.push(`dpr:${dpr}`)), + setSize: mock((width: number, height: number) => calls.push(`size:${width}x${height}`)), + xr: { enabled: false, isPresenting: false }, + } + const r3fXR = { + disconnect: mock(() => calls.push('disconnect')), + } + const renderFrame = (() => undefined) as XRFrameRequestCallback + + const restore = await takeOverXRFrameLoop(renderer, r3fXR, renderFrame, { + dpr: 1.5, + height: 800, + width: 936, + }) + + expect(calls).toEqual(['disconnect', 'dpr:1.5', 'size:936x800', 'set-loop']) + expect(renderer.xr.enabled).toBe(true) + expect(setAnimationLoop).toHaveBeenCalledWith(renderFrame) + + restore() + + expect(calls).toEqual(['disconnect', 'dpr:1.5', 'size:936x800', 'set-loop', 'clear-loop']) + expect(renderer.xr.enabled).toBe(false) + }) + + test('keeps Three’s XR wrapper alive until the presenting session ends', async () => { + const setAnimationLoop = mock(async () => undefined) + const renderer = { + setAnimationLoop, + setPixelRatio: mock(() => undefined), + setSize: mock(() => undefined), + xr: { enabled: false, isPresenting: false }, + } + + const restore = await takeOverXRFrameLoop( + renderer, + null, + (() => undefined) as XRFrameRequestCallback, + { dpr: 1, height: 800, width: 1280 }, + ) + renderer.xr.isPresenting = true + restore() + + expect(setAnimationLoop).toHaveBeenCalledTimes(1) + expect(renderer.xr.enabled).toBe(true) + + renderer.xr.isPresenting = false + stopXRFrameLoop(renderer) + + expect(setAnimationLoop).toHaveBeenLastCalledWith(null) + expect(renderer.xr.enabled).toBe(false) + }) +}) diff --git a/packages/viewer/src/xr/frame-loop.ts b/packages/viewer/src/xr/frame-loop.ts new file mode 100644 index 0000000000..0ce659bd4b --- /dev/null +++ b/packages/viewer/src/xr/frame-loop.ts @@ -0,0 +1,135 @@ +export type XRFrameLoopRenderer = { + setAnimationLoop(callback: XRFrameRequestCallback | null): Promise | void + setPixelRatio(dpr: number): void + setSize(width: number, height: number, updateStyle?: boolean): void + xr: { + enabled: boolean + isPresenting: boolean + } +} + +type XRViewport = { + dpr: number + height: number + width: number +} + +type R3FXRConnection = { + disconnect(): void +} + +type XRRenderDriverRenderer = { + render(scene: unknown, camera: unknown): void + xr: { + cameraAutoUpdate: boolean + getCamera(): unknown + updateCamera(camera: unknown): void + } +} + +type XRUnionCamera = { + cameras?: { layers?: { mask: number } }[] + layers?: { mask: number } + parent?: unknown | null +} + +type XRBaseCamera = { + parent?: unknown | null +} + +type R3FFrameState = { + internal: { priority: number } +} + +export function advanceXRFrameWithoutDesktopRender(state: R3FFrameState, advanceFrame: () => void) { + const renderPriority = state.internal.priority + state.internal.priority = renderPriority + 1 + try { + advanceFrame() + } finally { + state.internal.priority = renderPriority + } +} + +export function unifyXRStereoCameraLayers(camera: XRUnionCamera) { + const mask = camera.layers?.mask + if (mask === undefined) return + // Three reserves layers 1 and 2 for left/right-eye visibility and removes + // one from each sub-camera. Pascal uses those layers for overlays and zones, + // so direct immersive presentation must render the union in both eyes. + for (const subCamera of camera.cameras ?? []) { + if (subCamera.layers) subCamera.layers.mask = mask + } +} + +export function shouldPauseFrameLimiterForXR(paused: boolean, session?: XRSession) { + return paused || session != null +} + +export function ownsXRFrameLoopBinding(activeBinding: symbol | null, binding: symbol) { + return activeBinding === binding +} + +export function shouldMountPostProcessingRenderDriver(immersiveXR: boolean) { + return !immersiveXR +} + +export function renderImmersiveXRFrame( + renderer: XRRenderDriverRenderer, + scene: unknown, + camera: unknown, +) { + const xrCamera = renderer.xr.getCamera() as XRUnionCamera + const baseCamera = camera as XRBaseCamera + const originalParent = baseCamera.parent + + // Three derives the stereo eye matrices from the parent of the application + // camera passed to updateCamera(). During our renderer-owned XR loop that is + // the preserved desktop camera, while parents the XR ArrayCamera. + // Borrow the ArrayCamera's origin only for the update so tracked inputs and + // both eyes are evaluated in the same world space. + if (xrCamera.parent != null) baseCamera.parent = xrCamera.parent + try { + renderer.xr.updateCamera(camera) + } finally { + baseCamera.parent = originalParent + } + unifyXRStereoCameraLayers(xrCamera) + const cameraAutoUpdate = renderer.xr.cameraAutoUpdate + renderer.xr.cameraAutoUpdate = false + try { + // Pass the application's base camera. With cameraAutoUpdate disabled the + // renderer's XR path substitutes the already-updated stereo camera itself; + // passing that ArrayCamera back as the base camera corrupts the second eye. + renderer.render(scene, camera) + } finally { + renderer.xr.cameraAutoUpdate = cameraAutoUpdate + } +} + +export async function takeOverXRFrameLoop( + renderer: XRFrameLoopRenderer, + r3fXR: R3FXRConnection | null, + renderFrame: XRFrameRequestCallback, + viewport: XRViewport, +) { + // R3F 9.6 still drives the legacy WebGL XR manager. Three's unified + // renderer owns its XR loop instead, so the viewer supplies R3F's frame + // callback through the renderer and disconnects the incompatible listener. + r3fXR?.disconnect() + renderer.setPixelRatio(viewport.dpr) + renderer.setSize(viewport.width, viewport.height, false) + renderer.xr.enabled = true + await renderer.setAnimationLoop(renderFrame) + + return () => { + if (renderer.xr.isPresenting) return + renderer.xr.enabled = false + void renderer.setAnimationLoop(null) + } +} + +export function stopXRFrameLoop(renderer: XRFrameLoopRenderer) { + renderer.xr.enabled = false + void renderer.setAnimationLoop(null) +} diff --git a/packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts b/packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts new file mode 100644 index 0000000000..0bbf567a27 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts @@ -0,0 +1,4 @@ +import { Euler, Vector3 } from 'three' + +export const GOD_ORIGIN_ROTATION = new Euler(0, 0, 0) +export const GOD_ORIGIN_POSITION = new Vector3(0, 4.5, 8) diff --git a/packages/viewer/src/xr/god-mode/index.ts b/packages/viewer/src/xr/god-mode/index.ts new file mode 100644 index 0000000000..9aa086e3e5 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/index.ts @@ -0,0 +1,3 @@ +export { GOD_ORIGIN_POSITION, GOD_ORIGIN_ROTATION } from './constants/god-mode-constants' +export { requestGodScaleReset, useGodScaleView } from './store/god-mode-view-store' +export { GodModeControls } from './ui/god-mode-controls' diff --git a/packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx b/packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx new file mode 100644 index 0000000000..8ea0e122fe --- /dev/null +++ b/packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useFrame } from '@react-three/fiber' +import { useXRInputSourceStateContext, XRSpace } from '@react-three/xr' +import { useEffect, useRef } from 'react' +import { type Object3D, Vector3 } from 'three' +import { advancePalmGrab, type PalmGrabPose } from '../lib/palm-grab' +import { clearGodScaleHandState, updateGodScaleHandState } from '../store/god-mode-hand-store' + +export function GodModeHandControls() { + const state = useXRInputSourceStateContext('hand') + const wrist = useRef(null) + const middleFingerTip = useRef(null) + const middleMetacarpal = useRef(null) + const ringMetacarpal = useRef(null) + const ringFingerTip = useRef(null) + const pinkyMetacarpal = useRef(null) + const pinkyFingerTip = useRef(null) + const wristPosition = useRef(new Vector3()) + const middleMetacarpalPosition = useRef(new Vector3()) + const middleFingerPosition = useRef(new Vector3()) + const ringMetacarpalPosition = useRef(new Vector3()) + const ringFingerPosition = useRef(new Vector3()) + const pinkyMetacarpalPosition = useRef(new Vector3()) + const pinkyFingerPosition = useRef(new Vector3()) + const palmGrabPosition = useRef(new Vector3()) + const palmGrabState = useRef({ elapsed: 0, grabbed: false }) + const palmGrabPose = useRef(null) + const handedness = state.inputSource.handedness + + useEffect(() => () => clearGodScaleHandState(handedness), [handedness]) + + useFrame((_, delta) => { + if (wrist.current?.visible) wrist.current.getWorldPosition(wristPosition.current) + if (middleMetacarpal.current?.visible) { + middleMetacarpal.current.getWorldPosition(middleMetacarpalPosition.current) + } + if (middleFingerTip.current?.visible) { + middleFingerTip.current.getWorldPosition(middleFingerPosition.current) + } + if (ringMetacarpal.current?.visible) { + ringMetacarpal.current.getWorldPosition(ringMetacarpalPosition.current) + } + if (ringFingerTip.current?.visible) { + ringFingerTip.current.getWorldPosition(ringFingerPosition.current) + } + if (pinkyMetacarpal.current?.visible) { + pinkyMetacarpal.current.getWorldPosition(pinkyMetacarpalPosition.current) + } + if (pinkyFingerTip.current?.visible) { + pinkyFingerTip.current.getWorldPosition(pinkyFingerPosition.current) + } + + const tracked = Boolean( + wrist.current?.visible && + middleMetacarpal.current?.visible && + middleFingerTip.current?.visible && + ringMetacarpal.current?.visible && + ringFingerTip.current?.visible && + pinkyMetacarpal.current?.visible && + pinkyFingerTip.current?.visible, + ) + palmGrabPose.current ??= { + middle: { metacarpal: middleMetacarpalPosition.current, tip: middleFingerPosition.current }, + pinky: { metacarpal: pinkyMetacarpalPosition.current, tip: pinkyFingerPosition.current }, + ring: { metacarpal: ringMetacarpalPosition.current, tip: ringFingerPosition.current }, + wrist: wristPosition.current, + } + + const grabbed = advancePalmGrab( + palmGrabState.current, + tracked ? palmGrabPose.current : null, + delta, + ) + if (tracked) { + palmGrabPosition.current + .copy(middleMetacarpalPosition.current) + .add(ringMetacarpalPosition.current) + .multiplyScalar(0.5) + } + updateGodScaleHandState(handedness, grabbed, tracked, palmGrabPosition.current) + }) + + return ( + <> + + + + + + + + + ) +} diff --git a/packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts b/packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts new file mode 100644 index 0000000000..54c4b3f669 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts @@ -0,0 +1,36 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { advancePalmGrab, PALM_GRAB_HOLD_SECONDS } from './palm-grab' + +const wrist = { x: 0, y: 0, z: 0 } +const closedPose = { + wrist, + middle: { metacarpal: { x: 1, y: 0, z: 0 }, tip: { x: 1.4, y: 0, z: 0 } }, + ring: { metacarpal: { x: 0, y: 1, z: 0 }, tip: { x: 0, y: 1.4, z: 0 } }, + pinky: { metacarpal: { x: -1, y: 0, z: 0 }, tip: { x: -1.4, y: 0, z: 0 } }, +} + +describe('palm grab', () => { + test('activates only after all three fingers remain curled for the hold time', () => { + const state = { grabbed: false, elapsed: 0 } + + expect(advancePalmGrab(state, closedPose, PALM_GRAB_HOLD_SECONDS - 0.01)).toBe(false) + expect(advancePalmGrab(state, closedPose, 0.01)).toBe(true) + }) + + test('stays grabbed through tracking noise and releases when a finger opens', () => { + const state = { grabbed: true, elapsed: PALM_GRAB_HOLD_SECONDS } + const partlyOpenPose = { + ...closedPose, + middle: { ...closedPose.middle, tip: { x: 2.8, y: 0, z: 0 } }, + } + const openPose = { + ...closedPose, + middle: { ...closedPose.middle, tip: { x: 3.4, y: 0, z: 0 } }, + } + + expect(advancePalmGrab(state, partlyOpenPose, 0.016)).toBe(true) + expect(advancePalmGrab(state, openPose, 0.016)).toBe(false) + }) +}) diff --git a/packages/viewer/src/xr/god-mode/lib/palm-grab.ts b/packages/viewer/src/xr/god-mode/lib/palm-grab.ts new file mode 100644 index 0000000000..51c4a8bbc9 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/lib/palm-grab.ts @@ -0,0 +1,74 @@ +export const PALM_GRAB_HOLD_SECONDS = 0.12 +export const PALM_GRAB_TRIGGER_EXTENSION = 2.55 +export const PALM_GRAB_RELEASE_EXTENSION = 3.2 + +type Point = { x: number; y: number; z: number } + +type FingerPose = { + metacarpal: Point + tip: Point +} + +export type PalmGrabPose = { + middle: FingerPose + pinky: FingerPose + ring: FingerPose + wrist: Point +} + +export type PalmGrabState = { + elapsed: number + grabbed: boolean +} + +const FINGERS = ['middle', 'ring', 'pinky'] as const + +function distance(first: Point, second: Point) { + return Math.hypot(first.x - second.x, first.y - second.y, first.z - second.z) +} + +function resolveFingerExtension(wrist: Point, finger: FingerPose) { + const palmLength = distance(wrist, finger.metacarpal) + if (!Number.isFinite(palmLength) || palmLength <= 1e-6) return Number.POSITIVE_INFINITY + return distance(wrist, finger.tip) / palmLength +} + +export function advancePalmGrab( + state: PalmGrabState, + pose: PalmGrabPose | null, + deltaSeconds: number, + enabled = true, +) { + const extensions = + pose && FINGERS.map((finger) => resolveFingerExtension(pose.wrist, pose[finger])) + if (state.grabbed) { + const held = + enabled && + extensions?.every( + (extension) => Number.isFinite(extension) && extension < PALM_GRAB_RELEASE_EXTENSION, + ) + if (held) return true + + state.grabbed = false + state.elapsed = 0 + return false + } + + const curled = + enabled && + extensions?.every( + (extension) => Number.isFinite(extension) && extension <= PALM_GRAB_TRIGGER_EXTENSION, + ) + + if (!curled) { + state.grabbed = false + state.elapsed = 0 + return false + } + + state.elapsed += Math.max(0, Number.isFinite(deltaSeconds) ? deltaSeconds : 0) + if (state.elapsed < PALM_GRAB_HOLD_SECONDS) return false + + state.grabbed = true + return true +} diff --git a/packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts b/packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts new file mode 100644 index 0000000000..869d4a4741 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts @@ -0,0 +1,109 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { Object3D, Vector3 } from 'three' +import { + applyGodScaleGesture, + resetGodScaleRoot, + resolveGodScalePan, + resolveGodScaleTransform, +} from './scale-interaction' + +describe('God-scale live gesture lifecycle', () => { + test('pans the scene root with a single grip', () => { + const root = new Object3D() + const gesture = { mode: null } + const leftPosition = new Vector3(0, 1, 0) + const rightPosition = new Vector3() + + applyGodScaleGesture({ root, gesture, mode: 'left', leftPosition, rightPosition }) + leftPosition.set(0.1, 1, 0) + applyGodScaleGesture({ root, gesture, mode: 'left', leftPosition, rightPosition }) + + expect(root.position.x).toBeCloseTo(0.3) + }) + + test('scales and rotates the scene root with two grips', () => { + const root = new Object3D() + const gesture = { mode: null } + const leftPosition = new Vector3(-1, 0, 0) + const rightPosition = new Vector3(1, 0, 0) + + applyGodScaleGesture({ root, gesture, mode: 'two', leftPosition, rightPosition }) + leftPosition.set(0, 0, -2) + rightPosition.set(0, 0, 2) + applyGodScaleGesture({ root, gesture, mode: 'two', leftPosition, rightPosition }) + + expect(root.scale.x).toBe(2) + expect(root.rotation.y).toBeCloseTo(-Math.PI / 2) + }) + + test('resets the scene root and cancels the active gesture', () => { + const root = new Object3D() + const gesture = { mode: 'two' as const } + root.position.set(4, -2, 7) + root.rotation.set(0.2, 1.1, -0.4) + root.scale.setScalar(3) + + resetGodScaleRoot(root, gesture) + + expect(root.position.toArray()).toEqual([0, 0, 0]) + expect(root.rotation.toArray().slice(0, 3)).toEqual([0, 0, 0]) + expect(root.scale.toArray()).toEqual([1, 1, 1]) + expect(gesture.mode).toBeNull() + }) +}) + +describe('God-scale transform math', () => { + test('pans by the movement of one grip', () => { + const result = resolveGodScalePan( + new Vector3(2, 0, -1), + new Vector3(0, 1, 0), + new Vector3(0.5, 1.25, -0.25), + ) + expect(result.toArray()).toEqual([2.5, 0.25, -1.25]) + }) + + test('scales around the two-grip midpoint and follows midpoint movement', () => { + const result = resolveGodScaleTransform({ + rootPosition: new Vector3(), + rootScale: 1, + startLeft: new Vector3(-1, 0, 0), + startRight: new Vector3(1, 0, 0), + currentLeft: new Vector3(-1.5, 0, 1), + currentRight: new Vector3(2.5, 0, 1), + }) + + expect(result.scale).toBe(2) + expect(result.position.toArray()).toEqual([0.5, 0, 1]) + expect(result.rotationY).toBe(0) + }) + + test('keeps scaling above the original maximum', () => { + const result = resolveGodScaleTransform({ + rootPosition: new Vector3(1, 0, 0), + rootScale: 10, + startLeft: new Vector3(-1, 0, 0), + startRight: new Vector3(1, 0, 0), + currentLeft: new Vector3(-4, 0, 0), + currentRight: new Vector3(4, 0, 0), + }) + + expect(result.scale).toBe(40) + expect(result.position.toArray()).toEqual([4, 0, 0]) + }) + + test('stops at the minimum scale while keeping the midpoint anchored', () => { + const result = resolveGodScaleTransform({ + rootPosition: new Vector3(2, 0, 0), + rootScale: 0.1, + startLeft: new Vector3(-1, 0, 0), + startRight: new Vector3(1, 0, 0), + currentLeft: new Vector3(-0.1, 0, 0), + currentRight: new Vector3(0.1, 0, 0), + }) + + expect(result.scale).toBe(0.05) + expect(result.position.toArray()).toEqual([1, 0, 0]) + }) +}) diff --git a/packages/viewer/src/xr/god-mode/lib/scale-interaction.ts b/packages/viewer/src/xr/god-mode/lib/scale-interaction.ts new file mode 100644 index 0000000000..d8d1934fa4 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/lib/scale-interaction.ts @@ -0,0 +1,155 @@ +import { type Object3D, Vector3 } from 'three' + +const Y_AXIS = new Vector3(0, 1, 0) +const GOD_SCALE_MIN = 0.05 + +export type GodScaleGestureMode = 'left' | 'right' | 'two' + +export type GodScaleGesture = { + mode: GodScaleGestureMode | null + rootPosition?: Vector3 + rootRotationY?: number + rootScale?: number + startGrip?: Vector3 + startLeft?: Vector3 + startRight?: Vector3 +} + +export function isGodScaleInteractionEnabled(gestureMode: GodScaleGestureMode | null) { + return gestureMode != null +} + +export function resetGodScaleRoot(root: Object3D, gesture: GodScaleGesture) { + root.position.set(0, 0, 0) + root.rotation.set(0, 0, 0) + root.scale.setScalar(1) + gesture.mode = null +} + +export function resolveGodScalePan( + rootPosition: Vector3, + startGrip: Vector3, + currentGrip: Vector3, + target = new Vector3(), + sensitivity = 1, +) { + return target.copy(currentGrip).sub(startGrip).multiplyScalar(sensitivity).add(rootPosition) +} + +export function resolveGodScaleTransform({ + rootPosition, + rootScale, + startLeft, + startRight, + currentLeft, + currentRight, + targetPosition = new Vector3(), + translationSensitivity = 1, + scaleSensitivity = 1, + scaleAroundMidpoint = true, +}: { + rootPosition: Vector3 + rootScale: number + startLeft: Vector3 + startRight: Vector3 + currentLeft: Vector3 + currentRight: Vector3 + targetPosition?: Vector3 + translationSensitivity?: number + scaleSensitivity?: number + scaleAroundMidpoint?: boolean +}) { + const startMidpoint = startLeft.clone().add(startRight).multiplyScalar(0.5) + const currentMidpoint = currentLeft.clone().add(currentRight).multiplyScalar(0.5) + const startVector = startRight.clone().sub(startLeft) + const currentVector = currentRight.clone().sub(currentLeft) + const startDistance = startVector.length() + const currentDistance = currentVector.length() + const rawScaleRatio = startDistance > 1e-6 ? currentDistance / startDistance : 1 + const scaleRatio = rawScaleRatio ** scaleSensitivity + const scale = Math.max(GOD_SCALE_MIN, rootScale * scaleRatio) + const effectiveScaleRatio = scale / rootScale + const rotationY = + Math.atan2(startVector.z, startVector.x) - Math.atan2(currentVector.z, currentVector.x) + const translatedMidpoint = currentMidpoint + .sub(startMidpoint) + .multiplyScalar(translationSensitivity) + + if (scaleAroundMidpoint) { + targetPosition + .copy(rootPosition) + .sub(startMidpoint) + .multiplyScalar(effectiveScaleRatio) + .applyAxisAngle(Y_AXIS, rotationY) + .add(translatedMidpoint.add(startMidpoint)) + } else { + targetPosition.copy(rootPosition).add(translatedMidpoint) + } + + return { position: targetPosition, rotationY, scale } +} + +export function applyGodScaleGesture({ + root, + gesture, + mode, + leftPosition, + rightPosition, + targetPosition = new Vector3(), + translationSensitivity = 3, + scaleSensitivity = 1, + scaleAroundMidpoint = false, +}: { + root: Object3D + gesture: GodScaleGesture + mode: GodScaleGestureMode + leftPosition: Vector3 + rightPosition: Vector3 + targetPosition?: Vector3 + translationSensitivity?: number + scaleSensitivity?: number + scaleAroundMidpoint?: boolean +}) { + if (gesture.mode !== mode) { + gesture.mode = mode + gesture.rootPosition = root.position.clone() + gesture.rootScale = root.scale.x + gesture.rootRotationY = root.rotation.y + if (mode === 'two') { + gesture.startLeft = leftPosition.clone() + gesture.startRight = rightPosition.clone() + } else { + gesture.startGrip = (mode === 'left' ? leftPosition : rightPosition).clone() + } + } + + if (mode === 'two') { + const result = resolveGodScaleTransform({ + rootPosition: gesture.rootPosition!, + rootScale: gesture.rootScale!, + startLeft: gesture.startLeft!, + startRight: gesture.startRight!, + currentLeft: leftPosition, + currentRight: rightPosition, + targetPosition, + translationSensitivity, + scaleSensitivity, + scaleAroundMidpoint, + }) + root.position.copy(result.position) + root.scale.setScalar(result.scale) + root.rotation.y = gesture.rootRotationY! + result.rotationY + return + } + + const currentGrip = mode === 'left' ? leftPosition : rightPosition + root.position.copy( + resolveGodScalePan( + gesture.rootPosition!, + gesture.startGrip!, + currentGrip, + targetPosition, + translationSensitivity, + ), + ) +} diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts b/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts new file mode 100644 index 0000000000..fcb906b779 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts @@ -0,0 +1,20 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { beforeEach, describe, expect, test } from 'bun:test' +import { Vector3 } from 'three' +import { + clearGodScaleHandState, + getGodScaleHandState, + updateGodScaleHandState, +} from './god-mode-hand-store' + +describe('God-scale hand state', () => { + beforeEach(() => clearGodScaleHandState('left')) + + test('publishes a palm gesture as the same grab used by controllers', () => { + updateGodScaleHandState('left', true, true, new Vector3(1, 2, 3)) + + expect(getGodScaleHandState('left')).toMatchObject({ grabbed: true, tracked: true }) + expect(getGodScaleHandState('left').position.toArray()).toEqual([1, 2, 3]) + }) +}) diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts b/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts new file mode 100644 index 0000000000..635f3eb7e9 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts @@ -0,0 +1,48 @@ +import { Vector3 } from 'three' + +type GodModeHandedness = 'left' | 'right' + +export type GodModeHandState = { + grabbed: boolean + position: Vector3 + tracked: boolean +} + +function createHandState(): GodModeHandState { + return { grabbed: false, position: new Vector3(), tracked: false } +} + +const hands: Record = { + left: createHandState(), + right: createHandState(), +} + +function isGodModeHandedness(handedness: XRHandedness): handedness is GodModeHandedness { + return handedness === 'left' || handedness === 'right' +} + +export function updateGodScaleHandState( + handedness: XRHandedness, + grabbed: boolean, + tracked: boolean, + position?: Vector3, +) { + if (!isGodModeHandedness(handedness)) return + const hand = hands[handedness] + hand.grabbed = grabbed + hand.tracked = tracked + if (tracked && position) hand.position.copy(position) +} + +export function getGodScaleHandState(handedness: GodModeHandedness) { + return hands[handedness] +} + +export function clearGodScaleHandState(handedness: XRHandedness) { + updateGodScaleHandState(handedness, false, false) +} + +export function clearGodScaleHandStates() { + clearGodScaleHandState('left') + clearGodScaleHandState('right') +} diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts b/packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts new file mode 100644 index 0000000000..669e6342d2 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts @@ -0,0 +1,15 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { beforeEach, describe, expect, test } from 'bun:test' +import { requestGodScaleReset, useGodScaleView } from './god-mode-view-store' + +describe('God-scale reset requests', () => { + beforeEach(() => useGodScaleView.setState({ resetRequest: 0 })) + + test('publishes every reset request', () => { + requestGodScaleReset() + requestGodScaleReset() + + expect(useGodScaleView.getState().resetRequest).toBe(2) + }) +}) diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts b/packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts new file mode 100644 index 0000000000..869b0442b3 --- /dev/null +++ b/packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand' + +type GodModeViewState = { + requestReset(): void + resetRequest: number +} + +export const useGodScaleView = create((set) => ({ + resetRequest: 0, + requestReset: () => set((state) => ({ resetRequest: state.resetRequest + 1 })), +})) + +export function requestGodScaleReset() { + useGodScaleView.getState().requestReset() +} diff --git a/packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx b/packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx new file mode 100644 index 0000000000..64983d225f --- /dev/null +++ b/packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx @@ -0,0 +1,172 @@ +'use client' + +import { useFrame } from '@react-three/fiber' +import { useXR, useXRInputSourceState, type XRControllerState } from '@react-three/xr' +import { type RefObject, useEffect, useRef } from 'react' +import { type Object3D, Vector3 } from 'three' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' +import { GOD_ORIGIN_POSITION, GOD_ORIGIN_ROTATION } from '../constants/god-mode-constants' +import { + applyGodScaleGesture, + type GodScaleGesture, + type GodScaleGestureMode, + isGodScaleInteractionEnabled, + resetGodScaleRoot, +} from '../lib/scale-interaction' +import { getGodScaleHandState } from '../store/god-mode-hand-store' +import { useGodScaleView } from '../store/god-mode-view-store' + +function isControllerGrabPressed(state: XRControllerState | undefined) { + if (state?.gamepad?.['xr-standard-squeeze']?.state === 'pressed') return true + return state?.inputSource.gamepad?.buttons[1]?.pressed === true +} + +function getGripPosition( + state: XRControllerState | undefined, + frame: XRFrame | undefined, + referenceSpace: XRReferenceSpace | undefined, + origin: Object3D | undefined, + target: Vector3, +) { + if (frame && referenceSpace && state?.inputSource.gripSpace) { + const pose = frame.getPose(state.inputSource.gripSpace, referenceSpace) + if (pose) { + target.set(pose.transform.position.x, pose.transform.position.y, pose.transform.position.z) + origin?.localToWorld(target) + return true + } + } + + if (!state?.object) return false + const gripSpaceObject = state.object.parent ?? state.object + gripSpaceObject.updateWorldMatrix(true, false) + gripSpaceObject.getWorldPosition(target) + return true +} + +function resetGestureOnRequest( + rootRef: RefObject, + gesture: RefObject, + resetRequest: number, + handledResetRequest: RefObject, +) { + if (handledResetRequest.current === resetRequest || !rootRef.current) return + resetGodScaleRoot(rootRef.current, gesture.current) + handledResetRequest.current = resetRequest +} + +function GodScaleController({ sceneRootRef }: { sceneRootRef: RefObject }) { + const leftController = useXRInputSourceState('controller', 'left') + const rightController = useXRInputSourceState('controller', 'right') + const referenceSpace = useXR((state) => state.originReferenceSpace) + const origin = useXR((state) => state.origin) + const resetRequest = useGodScaleView((state) => state.resetRequest) + const playerMode = useXRPlayerMode((state) => state.mode) + const gesture = useRef({ mode: null }) + const handledResetRequest = useRef(resetRequest) + const leftPosition = useRef(new Vector3()) + const rightPosition = useRef(new Vector3()) + const nextPosition = useRef(new Vector3()) + + useEffect(() => { + resetGestureOnRequest(sceneRootRef, gesture, resetRequest, handledResetRequest) + }, [resetRequest, sceneRootRef]) + + useFrame((_, __, frame) => { + const root = sceneRootRef.current + const leftPressed = isControllerGrabPressed(leftController) + const rightPressed = isControllerGrabPressed(rightController) + const mode: GodScaleGestureMode | null = + leftPressed && rightPressed ? 'two' : leftPressed ? 'left' : rightPressed ? 'right' : null + + if (!root || playerMode !== XR_PLAYER_MODES.GOD || !isGodScaleInteractionEnabled(mode)) { + gesture.current.mode = null + return + } + + const hasLeftPosition = + !leftPressed || + getGripPosition(leftController, frame, referenceSpace, origin, leftPosition.current) + const hasRightPosition = + !rightPressed || + getGripPosition(rightController, frame, referenceSpace, origin, rightPosition.current) + if (!hasLeftPosition || !hasRightPosition) { + gesture.current.mode = null + return + } + + applyGodScaleGesture({ + gesture: gesture.current, + leftPosition: leftPosition.current, + mode, + rightPosition: rightPosition.current, + root, + targetPosition: nextPosition.current, + }) + }) + + return null +} + +function GodScaleHandController({ sceneRootRef }: { sceneRootRef: RefObject }) { + const resetRequest = useGodScaleView((state) => state.resetRequest) + const playerMode = useXRPlayerMode((state) => state.mode) + const gesture = useRef({ mode: null }) + const handledResetRequest = useRef(resetRequest) + const nextPosition = useRef(new Vector3()) + + useEffect(() => { + resetGestureOnRequest(sceneRootRef, gesture, resetRequest, handledResetRequest) + }, [resetRequest, sceneRootRef]) + + useFrame(() => { + const root = sceneRootRef.current + const leftHand = getGodScaleHandState('left') + const rightHand = getGodScaleHandState('right') + const mode: GodScaleGestureMode | null = + leftHand.grabbed && rightHand.grabbed + ? 'two' + : leftHand.grabbed + ? 'left' + : rightHand.grabbed + ? 'right' + : null + + if (!root || playerMode !== XR_PLAYER_MODES.GOD || !isGodScaleInteractionEnabled(mode)) { + gesture.current.mode = null + return + } + + applyGodScaleGesture({ + gesture: gesture.current, + leftPosition: leftHand.position, + mode, + rightPosition: rightHand.position, + root, + targetPosition: nextPosition.current, + }) + }) + + return null +} + +export function GodModeControls({ sceneRootRef }: { sceneRootRef: RefObject }) { + const origin = useXR((state) => state.origin) + const resetRequest = useGodScaleView((state) => state.resetRequest) + const handledResetRequest = useRef(resetRequest) + + useEffect(() => { + if (handledResetRequest.current === resetRequest || !sceneRootRef.current || !origin) return + resetGodScaleRoot(sceneRootRef.current, { mode: null }) + origin.position.copy(GOD_ORIGIN_POSITION) + origin.rotation.copy(GOD_ORIGIN_ROTATION) + handledResetRequest.current = resetRequest + }, [origin, resetRequest, sceneRootRef]) + + return ( + + + + + ) +} diff --git a/packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts b/packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts new file mode 100644 index 0000000000..afe3cf8b8f --- /dev/null +++ b/packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts @@ -0,0 +1,8 @@ +export const HAND_DEAD_ZONE = 0.015 +export const HAND_ZONE_RADIUS = 0.12 +export const HAND_SPEED = 1.5 +export const HAND_TURN_SPEED = Math.PI / 2 +export const HAND_PINCH_TOUCH_DISTANCE = 0.03 +export const HAND_PINCH_RELEASE_DISTANCE = 0.045 +export const SNAP_TURN_ANGLE = Math.PI / 6 +export const SNAP_TURN_THRESHOLD = 0.65 diff --git a/packages/viewer/src/xr/human-mode/index.ts b/packages/viewer/src/xr/human-mode/index.ts new file mode 100644 index 0000000000..8886044d0e --- /dev/null +++ b/packages/viewer/src/xr/human-mode/index.ts @@ -0,0 +1,5 @@ +export { + type LocomotionSettings, + useLocomotionSettings, +} from './store/locomotion-settings' +export { HumanModeControls } from './ui/human-mode-controls' diff --git a/packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx b/packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx new file mode 100644 index 0000000000..b5f090de4c --- /dev/null +++ b/packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useFrame, useThree } from '@react-three/fiber' +import { useXR, useXRInputSourceState } from '@react-three/xr' +import { useRef } from 'react' +import { Vector3 } from 'three' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' +import { pulseInputSource } from '../lib/haptics' +import { + getCameraRelativeRight, + getControllerThumbstickAxis, + normalizeMovementVector, + resolveLocomotionDelta, + setArtificialMovementSpeed, +} from '../lib/locomotion' +import { rotateOriginAroundCamera, translateOrigin } from '../lib/origin-navigation' +import { resolveSnapTurnDirection, SNAP_TURN_ANGLE, shouldSnapTurn } from '../lib/snap-turn' +import { resolveHumanCollisionTranslation } from '../store/collision-store' +import { useLocomotionSettings } from '../store/locomotion-settings' + +export function ControllerLocomotion() { + const leftController = useXRInputSourceState('controller', 'left') + const rightController = useXRInputSourceState('controller', 'right') + const origin = useXR((state) => state.origin) + const camera = useThree((state) => state.camera) + const mode = useXRPlayerMode((state) => state.mode) + const moveSpeed = useLocomotionSettings((state) => state.moveSpeed) + const turnSensitivity = useLocomotionSettings((state) => state.turnSensitivity) + const direction = useRef(new Vector3()) + const right = useRef(new Vector3()) + const movement = useRef(new Vector3()) + const resolvedMovement = useRef(new Vector3()) + const playerPosition = useRef(new Vector3()) + const resolvedPlayerPosition = useRef(new Vector3()) + const previousTurnDirection = useRef(0) + const cameraBeforeTurn = useRef(new Vector3()) + const cameraAfterTurn = useRef(new Vector3()) + + useFrame((_, delta) => { + const x = getControllerThumbstickAxis(leftController, 0) + const y = getControllerThumbstickAxis(leftController, 1) + const rightX = getControllerThumbstickAxis(rightController, 0) + if (mode !== XR_PLAYER_MODES.HUMAN || !origin) { + setArtificialMovementSpeed(0) + previousTurnDirection.current = 0 + return + } + + const locomotionDelta = resolveLocomotionDelta(delta) + setArtificialMovementSpeed(Math.min(1, Math.hypot(x, y)) * moveSpeed) + if (Math.max(Math.abs(x), Math.abs(y)) > 0.1) { + camera.getWorldDirection(direction.current) + direction.current.y = 0 + direction.current.normalize() + getCameraRelativeRight(direction.current, right.current) + const normalized = normalizeMovementVector(x, y) + movement.current.copy(right.current).multiplyScalar(normalized.x) + movement.current.addScaledVector(direction.current, -normalized.z) + movement.current.multiplyScalar(moveSpeed * locomotionDelta) + camera.getWorldPosition(playerPosition.current) + resolveHumanCollisionTranslation( + playerPosition.current, + movement.current, + resolvedMovement.current, + ) + translateOrigin( + origin, + resolvedMovement.current, + playerPosition.current, + resolvedPlayerPosition.current, + ) + } + + const turnDirection = resolveSnapTurnDirection(rightX) + if (shouldSnapTurn(previousTurnDirection.current, turnDirection)) { + rotateOriginAroundCamera( + origin, + camera, + -turnDirection * SNAP_TURN_ANGLE * turnSensitivity, + cameraBeforeTurn.current, + cameraAfterTurn.current, + ) + pulseInputSource(rightController?.inputSource, 0.25, 35) + } + previousTurnDirection.current = turnDirection + }) + + return null +} diff --git a/packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx b/packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx new file mode 100644 index 0000000000..821930b536 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useFrame, useThree } from '@react-three/fiber' +import { useXR, useXRInputSourceStateContext, XRSpace } from '@react-three/xr' +import { useEffect, useRef } from 'react' +import { type Object3D, Vector3 } from 'three' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' +import { + getHandLocomotionZoneCenter, + isInsideHandLocomotionZone, + normalizeHandLocomotionOffset, + resolveHandPinching, + resolveHandTurnDelta, +} from '../lib/hand-locomotion' +import { isPalmFacingUp } from '../lib/hand-pose' +import { pulseInputSource } from '../lib/haptics' +import { + getCameraRelativeRight, + normalizeMovementVector, + resolveLocomotionDelta, + setArtificialMovementSpeed, +} from '../lib/locomotion' +import { rotateOriginAroundCamera, translateOrigin } from '../lib/origin-navigation' +import { resolveHumanCollisionTranslation } from '../store/collision-store' +import { + hideHandLocomotionJoystick, + setHandLocomotionState, + showHandLocomotionJoystick, +} from '../store/hand-locomotion-joystick' +import { useLocomotionSettings } from '../store/locomotion-settings' + +const LOCOMOTION_HAND = 'left' +const TURN_HAND = 'right' + +export function HumanModeHandControls() { + const state = useXRInputSourceStateContext('hand') + const origin = useXR((xrState) => xrState.origin) + const camera = useThree((threeState) => threeState.camera) + const mode = useXRPlayerMode((playerState) => playerState.mode) + const moveSpeed = useLocomotionSettings((settings) => settings.moveSpeed) + const turnSensitivity = useLocomotionSettings((settings) => settings.turnSensitivity) + const indexTip = useRef(null) + const thumbTip = useRef(null) + const middleTip = useRef(null) + const wrist = useRef(null) + const indexMetacarpal = useRef(null) + const pinkyMetacarpal = useRef(null) + const indexPosition = useRef(new Vector3()) + const thumbPosition = useRef(new Vector3()) + const middlePosition = useRef(new Vector3()) + const wristPosition = useRef(new Vector3()) + const indexMetacarpalPosition = useRef(new Vector3()) + const pinkyMetacarpalPosition = useRef(new Vector3()) + const localHandPosition = useRef(new Vector3()) + const cameraLocalPosition = useRef(new Vector3()) + const zoneCenter = useRef(new Vector3()) + const pinchOrigin = useRef(new Vector3()) + const direction = useRef(new Vector3()) + const right = useRef(new Vector3()) + const movement = useRef(new Vector3()) + const resolvedMovement = useRef(new Vector3()) + const playerPosition = useRef(new Vector3()) + const resolvedPlayerPosition = useRef(new Vector3()) + const cameraBeforeTurn = useRef(new Vector3()) + const cameraAfterTurn = useRef(new Vector3()) + const pinching = useRef(false) + const active = useRef(false) + const controlOriginSet = useRef(false) + const controlState = useRef<'idle' | 'ready'>('idle') + const handedness = state.inputSource.handedness + + useEffect( + () => () => { + if (handedness === 'left' || handedness === 'right') { + hideHandLocomotionJoystick(handedness) + } + if (handedness === LOCOMOTION_HAND) setArtificialMovementSpeed(0) + }, + [handedness], + ) + + useFrame((_, delta) => { + if (handedness !== 'left' && handedness !== 'right') return + const tracked = Boolean( + indexTip.current?.visible && + thumbTip.current?.visible && + middleTip.current?.visible && + wrist.current?.visible && + indexMetacarpal.current?.visible && + pinkyMetacarpal.current?.visible, + ) + if (tracked) { + indexTip.current!.getWorldPosition(indexPosition.current) + thumbTip.current!.getWorldPosition(thumbPosition.current) + middleTip.current!.getWorldPosition(middlePosition.current) + wrist.current!.getWorldPosition(wristPosition.current) + indexMetacarpal.current!.getWorldPosition(indexMetacarpalPosition.current) + pinkyMetacarpal.current!.getWorldPosition(pinkyMetacarpalPosition.current) + } + const nextPinching = resolveHandPinching( + pinching.current, + tracked ? thumbPosition.current.distanceTo(middlePosition.current) : Number.POSITIVE_INFINITY, + ) + pinching.current = nextPinching + + if (mode !== XR_PLAYER_MODES.HUMAN || !origin || !tracked) { + active.current = false + controlOriginSet.current = false + hideHandLocomotionJoystick(handedness) + if (handedness === LOCOMOTION_HAND) setArtificialMovementSpeed(0) + return + } + + const palmUp = isPalmFacingUp( + wristPosition.current, + indexMetacarpalPosition.current, + pinkyMetacarpalPosition.current, + handedness, + ) + localHandPosition.current.copy(indexPosition.current) + origin.worldToLocal(localHandPosition.current) + camera.getWorldPosition(cameraLocalPosition.current) + origin.worldToLocal(cameraLocalPosition.current) + getHandLocomotionZoneCenter(handedness, zoneCenter.current, cameraLocalPosition.current) + const insideZone = + palmUp && + isInsideHandLocomotionZone(localHandPosition.current, handedness, cameraLocalPosition.current) + const nextState = insideZone ? 'ready' : 'idle' + if (controlState.current !== nextState && !active.current) { + controlState.current = nextState + setHandLocomotionState(handedness, nextState, zoneCenter.current) + } + + if (!pinching.current || !palmUp) { + active.current = false + controlOriginSet.current = false + hideHandLocomotionJoystick(handedness) + if (handedness === LOCOMOTION_HAND) setArtificialMovementSpeed(0) + return + } + if (!active.current && insideZone) { + active.current = true + pulseInputSource(state.inputSource, 0.2, 35) + } + if (!active.current) return + if (!controlOriginSet.current) { + pinchOrigin.current.copy(zoneCenter.current) + controlOriginSet.current = true + showHandLocomotionJoystick(pinchOrigin.current, handedness) + return + } + + const locomotionDelta = resolveLocomotionDelta(delta) + if (handedness === TURN_HAND) { + const turnDelta = + resolveHandTurnDelta(localHandPosition.current.x - pinchOrigin.current.x, locomotionDelta) * + turnSensitivity + if (turnDelta !== 0) { + rotateOriginAroundCamera( + origin, + camera, + turnDelta, + cameraBeforeTurn.current, + cameraAfterTurn.current, + ) + } + return + } + + const inputX = normalizeHandLocomotionOffset( + localHandPosition.current.x - pinchOrigin.current.x, + ) + const inputZ = normalizeHandLocomotionOffset( + localHandPosition.current.z - pinchOrigin.current.z, + ) + setArtificialMovementSpeed(Math.min(1, Math.hypot(inputX, inputZ)) * moveSpeed) + if (inputX === 0 && inputZ === 0) return + camera.getWorldDirection(direction.current) + direction.current.y = 0 + direction.current.normalize() + getCameraRelativeRight(direction.current, right.current) + const normalized = normalizeMovementVector(inputX, inputZ) + movement.current.copy(right.current).multiplyScalar(normalized.x) + movement.current.addScaledVector(direction.current, -normalized.z) + movement.current.multiplyScalar(moveSpeed * locomotionDelta) + camera.getWorldPosition(playerPosition.current) + resolveHumanCollisionTranslation( + playerPosition.current, + movement.current, + resolvedMovement.current, + ) + translateOrigin( + origin, + resolvedMovement.current, + playerPosition.current, + resolvedPlayerPosition.current, + ) + }) + + return ( + <> + + + + + + + + ) +} diff --git a/packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx b/packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx new file mode 100644 index 0000000000..ebaf6d07d9 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useFrame, useThree } from '@react-three/fiber' +import { useXR } from '@react-three/xr' +import { type RefObject, useEffect, useRef } from 'react' +import { type Mesh, type Object3D, Quaternion, Vector3 } from 'three' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' +import { resolveRoomScaleOriginCorrection } from '../lib/capsule-collision' +import { setActiveHumanColliders } from '../store/collision-store' + +function collectColliders(root: Object3D) { + const colliders: Mesh[] = [] + root.traverse((object) => { + const mesh = object as Mesh + if ( + mesh.isMesh && + mesh.visible && + mesh.geometry?.boundsTree && + mesh.userData.excludeFromBvh !== true + ) { + colliders.push(mesh) + } + }) + return colliders +} + +export function HumanCollisionRig({ sceneRootRef }: { sceneRootRef: RefObject }) { + const camera = useThree((state) => state.camera) + const origin = useXR((state) => state.origin) + const mode = useXRPlayerMode((state) => state.mode) + const colliders = useRef([]) + const collected = useRef(false) + const hasViewerPose = useRef(false) + const previousLocalPosition = useRef(new Vector3()) + const currentLocalPosition = useRef(new Vector3()) + const currentWorldPosition = useRef(new Vector3()) + const previousWorldPosition = useRef(new Vector3()) + const physicalMovement = useRef(new Vector3()) + const originWorldRotation = useRef(new Quaternion()) + const originCorrection = useRef(new Vector3()) + + useEffect(() => { + if (mode !== XR_PLAYER_MODES.HUMAN) { + colliders.current = [] + collected.current = false + hasViewerPose.current = false + setActiveHumanColliders([]) + } + }, [mode]) + + useFrame(() => { + if (mode !== XR_PLAYER_MODES.HUMAN || !origin || !sceneRootRef.current) return + if (!collected.current) { + colliders.current = collectColliders(sceneRootRef.current) + if (colliders.current.length > 0) { + collected.current = true + setActiveHumanColliders(colliders.current) + } + } + + camera.getWorldPosition(currentWorldPosition.current) + currentLocalPosition.current.copy(currentWorldPosition.current) + origin.worldToLocal(currentLocalPosition.current) + if (!hasViewerPose.current) { + previousLocalPosition.current.copy(currentLocalPosition.current) + previousWorldPosition.current.copy(currentWorldPosition.current) + hasViewerPose.current = true + return + } + + physicalMovement.current.copy(currentLocalPosition.current).sub(previousLocalPosition.current) + origin.getWorldQuaternion(originWorldRotation.current) + physicalMovement.current.applyQuaternion(originWorldRotation.current) + previousWorldPosition.current.copy(currentWorldPosition.current).sub(physicalMovement.current) + resolveRoomScaleOriginCorrection( + colliders.current, + previousWorldPosition.current, + currentWorldPosition.current, + originCorrection.current, + ) + origin.position.add(originCorrection.current) + previousLocalPosition.current.copy(currentLocalPosition.current) + }) + + useEffect(() => () => setActiveHumanColliders([]), []) + return null +} diff --git a/packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts b/packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts new file mode 100644 index 0000000000..2c0089e4fe --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts @@ -0,0 +1,45 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { BoxGeometry, Mesh, Vector3 } from 'three' +import { computeBoundsTree } from 'three-mesh-bvh' +import { resolveCapsuleTranslation, resolveRoomScaleOriginCorrection } from './capsule-collision' + +function createWallCollider() { + const geometry = new BoxGeometry(4, 3, 0.1) + ;(geometry as unknown as { computeBoundsTree: typeof computeBoundsTree }).computeBoundsTree = + computeBoundsTree + ;(geometry as unknown as { computeBoundsTree(): void }).computeBoundsTree() + const wall = new Mesh(geometry) + wall.position.y = 1.5 + wall.updateWorldMatrix(true, false) + return wall +} + +describe('Human capsule collision', () => { + test('stops a large movement before tunneling through a rendered wall', () => { + const wall = createWallCollider() + const movement = resolveCapsuleTranslation( + [wall], + new Vector3(0, 1.65, -1), + new Vector3(0, 0, 2), + new Vector3(), + ) + + expect(movement.z).toBeCloseTo(0.7, 2) + wall.geometry.dispose() + }) + + test('corrects room-scale walking that crosses a rendered wall', () => { + const wall = createWallCollider() + const correction = resolveRoomScaleOriginCorrection( + [wall], + new Vector3(0, 1.65, -1), + new Vector3(0, 1.65, 1), + new Vector3(), + ) + + expect(correction.z).toBeCloseTo(-1.3, 2) + wall.geometry.dispose() + }) +}) diff --git a/packages/viewer/src/xr/human-mode/lib/capsule-collision.ts b/packages/viewer/src/xr/human-mode/lib/capsule-collision.ts new file mode 100644 index 0000000000..0268e8b750 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/capsule-collision.ts @@ -0,0 +1,119 @@ +import { Box3, Line3, Matrix4, type Mesh, Quaternion, Vector3 } from 'three' + +const CAPSULE_RADIUS = 0.25 +const CAPSULE_SEGMENT_LENGTH = 0.8 +const CAPSULE_CENTER_FROM_EYE = 0.85 +const MAX_MOVEMENT_STEP = 0.1 +const COLLISION_ITERATIONS = 3 +const EPSILON = 1e-10 + +const inverseMatrix = new Matrix4() +const colliderScale = new Vector3() +const colliderPosition = new Vector3() +const colliderQuaternion = new Quaternion() +const worldSegment = new Line3(new Vector3(), new Vector3()) +const localSegment = new Line3(new Vector3(), new Vector3()) +const localBounds = new Box3() +const trianglePoint = new Vector3() +const capsulePoint = new Vector3() +const pushDirection = new Vector3() +const desiredWorldStart = new Vector3() +const resolvedWorldStart = new Vector3() +const correction = new Vector3() +const currentPosition = new Vector3() +const desiredPosition = new Vector3() +const stepMovement = new Vector3() +const roomMovement = new Vector3() +const resolvedRoomMovement = new Vector3() + +type BvhGeometry = Mesh['geometry'] & { + boundsTree?: { + shapecast(callbacks: { + intersectsBounds(bounds: Box3): boolean + intersectsTriangle(triangle: { + closestPointToSegment(segment: Line3, trianglePoint: Vector3, capsulePoint: Vector3): number + getNormal(target: Vector3): Vector3 + }): boolean + }): void + } +} + +function resolveColliderPenetration(collider: Mesh, eyePosition: Vector3) { + const geometry = collider.geometry as BvhGeometry + if (!geometry.boundsTree) return correction.set(0, 0, 0) + + collider.updateWorldMatrix(true, false) + inverseMatrix.copy(collider.matrixWorld).invert() + collider.matrixWorld.decompose(colliderPosition, colliderQuaternion, colliderScale) + const minimumScale = Math.max( + EPSILON, + Math.min(Math.abs(colliderScale.x), Math.abs(colliderScale.y), Math.abs(colliderScale.z)), + ) + const localRadius = CAPSULE_RADIUS / minimumScale + const halfSegment = CAPSULE_SEGMENT_LENGTH / 2 + const centerY = eyePosition.y - CAPSULE_CENTER_FROM_EYE + worldSegment.start.set(eyePosition.x, centerY + halfSegment, eyePosition.z) + worldSegment.end.set(eyePosition.x, centerY - halfSegment, eyePosition.z) + desiredWorldStart.copy(worldSegment.start) + localSegment.copy(worldSegment).applyMatrix4(inverseMatrix) + + for (let iteration = 0; iteration < COLLISION_ITERATIONS; iteration += 1) { + localBounds + .makeEmpty() + .expandByPoint(localSegment.start) + .expandByPoint(localSegment.end) + .expandByScalar(localRadius) + let collided = false + geometry.boundsTree.shapecast({ + intersectsBounds: (bounds) => bounds.intersectsBox(localBounds), + intersectsTriangle: (triangle) => { + const distance = triangle.closestPointToSegment(localSegment, trianglePoint, capsulePoint) + if (distance >= localRadius) return false + pushDirection.copy(capsulePoint).sub(trianglePoint) + if (pushDirection.lengthSq() <= EPSILON) triangle.getNormal(pushDirection) + else pushDirection.normalize() + localSegment.start.addScaledVector(pushDirection, localRadius - distance) + localSegment.end.addScaledVector(pushDirection, localRadius - distance) + collided = true + return false + }, + }) + if (!collided) break + } + + resolvedWorldStart.copy(localSegment.start).applyMatrix4(collider.matrixWorld) + return correction.copy(resolvedWorldStart).sub(desiredWorldStart) +} + +export function resolveCapsuleTranslation( + colliders: readonly Mesh[], + playerPosition: Vector3, + movement: Vector3, + target: Vector3, +) { + const distance = movement.length() + if (!Number.isFinite(distance)) return target.set(0, 0, 0) + const steps = Math.max(1, Math.ceil(distance / MAX_MOVEMENT_STEP)) + stepMovement.copy(movement).divideScalar(steps) + currentPosition.copy(playerPosition) + + for (let step = 0; step < steps; step += 1) { + desiredPosition.copy(currentPosition).add(stepMovement) + for (const collider of colliders) { + desiredPosition.add(resolveColliderPenetration(collider, desiredPosition)) + } + currentPosition.copy(desiredPosition) + } + return target.copy(currentPosition).sub(playerPosition) +} + +export function resolveRoomScaleOriginCorrection( + colliders: readonly Mesh[], + previousPlayerPosition: Vector3, + currentPlayerPosition: Vector3, + target: Vector3, +) { + roomMovement.copy(currentPlayerPosition).sub(previousPlayerPosition) + resolveCapsuleTranslation(colliders, previousPlayerPosition, roomMovement, resolvedRoomMovement) + return target.copy(resolvedRoomMovement).sub(roomMovement) +} diff --git a/packages/viewer/src/xr/human-mode/lib/comfort.ts b/packages/viewer/src/xr/human-mode/lib/comfort.ts new file mode 100644 index 0000000000..b1e0911b34 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/comfort.ts @@ -0,0 +1,7 @@ +export const COMFORT_REFERENCE_SPEED = 1.5 +export const MAX_COMFORT_OPACITY = 0.22 + +export function resolveComfortOpacity(speed: number, referenceSpeed = COMFORT_REFERENCE_SPEED) { + if (!Number.isFinite(speed) || !Number.isFinite(referenceSpeed) || referenceSpeed <= 0) return 0 + return MAX_COMFORT_OPACITY * Math.min(1, Math.abs(speed) / referenceSpeed) +} diff --git a/packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts b/packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts new file mode 100644 index 0000000000..1fab72ad35 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts @@ -0,0 +1,79 @@ +import { Vector3 } from 'three' +import { + HAND_DEAD_ZONE, + HAND_PINCH_RELEASE_DISTANCE, + HAND_PINCH_TOUCH_DISTANCE, + HAND_SPEED, + HAND_TURN_SPEED, + HAND_ZONE_RADIUS, +} from '../constants/human-mode-constants' + +export function resolveHandJoystickArrowRotations(handedness: XRHandedness) { + return handedness === 'right' + ? [Math.PI / 2, -Math.PI / 2] + : [0, Math.PI / 2, Math.PI, -Math.PI / 2] +} + +export function resolveHandControlLabel(handedness: XRHandedness) { + return handedness === 'left' ? 'MOVE' : 'TURN' +} + +export function resolveHandPinching(previousPinching: boolean, distance: number) { + if (!Number.isFinite(distance)) return false + return previousPinching + ? distance < HAND_PINCH_RELEASE_DISTANCE + : distance <= HAND_PINCH_TOUCH_DISTANCE +} + +const HAND_ZONE_HORIZONTAL_OFFSET = 0.2 +const HAND_ZONE_HEIGHT = 0.93 +const HAND_ZONE_DEPTH = -0.35 +const HAND_ZONE_HEAD_VERTICAL_OFFSET = -0.25 + +export function getHandLocomotionZoneCenter( + handedness: XRHandedness, + target = new Vector3(), + anchor?: Vector3, +) { + if (!anchor) + return target.set( + handedness === 'right' ? HAND_ZONE_HORIZONTAL_OFFSET : -HAND_ZONE_HORIZONTAL_OFFSET, + HAND_ZONE_HEIGHT, + HAND_ZONE_DEPTH, + ) + return target.set( + anchor.x + + (handedness === 'right' ? HAND_ZONE_HORIZONTAL_OFFSET : -HAND_ZONE_HORIZONTAL_OFFSET), + anchor.y + HAND_ZONE_HEAD_VERTICAL_OFFSET, + anchor.z + HAND_ZONE_DEPTH, + ) +} + +export function isInsideHandLocomotionZone( + position: Vector3, + handedness: XRHandedness, + anchor?: Vector3, +) { + if (handedness !== 'left' && handedness !== 'right') return false + const center = getHandLocomotionZoneCenter(handedness, new Vector3(), anchor) + return ( + Math.hypot(position.x - center.x, position.z - center.z) <= HAND_ZONE_RADIUS && + Math.abs(position.y - center.y) <= HAND_ZONE_RADIUS + ) +} + +export function normalizeHandLocomotionOffset(offset: number) { + const distance = Math.abs(offset) + if (!Number.isFinite(distance) || distance <= HAND_DEAD_ZONE) return 0 + const normalized = Math.min(1, (distance - HAND_DEAD_ZONE) / (HAND_ZONE_RADIUS - HAND_DEAD_ZONE)) + return Math.sign(offset) * normalized +} + +export function resolveHandLocomotionVelocity(offset: number, delta: number, speed = HAND_SPEED) { + if (!Number.isFinite(delta) || delta <= 0) return 0 + return normalizeHandLocomotionOffset(offset) * speed * delta +} + +export function resolveHandTurnDelta(offset: number, delta: number) { + return -normalizeHandLocomotionOffset(offset) * HAND_TURN_SPEED * Math.max(0, delta) +} diff --git a/packages/viewer/src/xr/human-mode/lib/hand-pose.ts b/packages/viewer/src/xr/human-mode/lib/hand-pose.ts new file mode 100644 index 0000000000..1869db48d5 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/hand-pose.ts @@ -0,0 +1,38 @@ +export const PALM_UP_DOT_THRESHOLD = 0.5 + +type Point = { x: number; y: number; z: number } + +function isFinitePoint(point: Point) { + return Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z) +} + +export function isPalmFacingUp( + wrist: Point, + indexMetacarpal: Point, + pinkyMetacarpal: Point, + handedness: XRHandedness, + threshold = PALM_UP_DOT_THRESHOLD, +) { + if ( + !isFinitePoint(wrist) || + !isFinitePoint(indexMetacarpal) || + !isFinitePoint(pinkyMetacarpal) || + (handedness !== 'left' && handedness !== 'right') + ) + return false + + const indexX = indexMetacarpal.x - wrist.x + const indexY = indexMetacarpal.y - wrist.y + const indexZ = indexMetacarpal.z - wrist.z + const pinkyX = pinkyMetacarpal.x - wrist.x + const pinkyY = pinkyMetacarpal.y - wrist.y + const pinkyZ = pinkyMetacarpal.z - wrist.z + const normalY = indexZ * pinkyX - indexX * pinkyZ + const normalLength = Math.hypot( + indexY * pinkyZ - indexZ * pinkyY, + normalY, + indexX * pinkyY - indexY * pinkyX, + ) + if (normalLength === 0) return false + return (normalY * (handedness === 'right' ? 1 : -1)) / normalLength >= threshold +} diff --git a/packages/viewer/src/xr/human-mode/lib/haptics.ts b/packages/viewer/src/xr/human-mode/lib/haptics.ts new file mode 100644 index 0000000000..11f8574a26 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/haptics.ts @@ -0,0 +1,10 @@ +export function pulseInputSource( + inputSource: XRInputSource | undefined, + intensity = 0.2, + duration = 35, +) { + const actuator = inputSource?.gamepad?.hapticActuators?.[0] + if (!actuator || typeof actuator.pulse !== 'function') return false + void actuator.pulse(intensity, duration) + return true +} diff --git a/packages/viewer/src/xr/human-mode/lib/human-input.test.ts b/packages/viewer/src/xr/human-mode/lib/human-input.test.ts new file mode 100644 index 0000000000..d030573cab --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/human-input.test.ts @@ -0,0 +1,61 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { Vector3 } from 'three' +import { HAND_TURN_SPEED, HAND_ZONE_RADIUS } from '../constants/human-mode-constants' +import { resolveComfortOpacity } from './comfort' +import { + getHandLocomotionZoneCenter, + isInsideHandLocomotionZone, + normalizeHandLocomotionOffset, + resolveHandControlLabel, + resolveHandLocomotionVelocity, + resolveHandPinching, + resolveHandTurnDelta, +} from './hand-locomotion' +import { resolveSnapTurnDirection, SNAP_TURN_ANGLE, shouldSnapTurn } from './snap-turn' + +describe('Human hand locomotion', () => { + test('mirrors body-relative activation zones', () => { + const anchor = new Vector3(3, 1.6, -4) + const left = getHandLocomotionZoneCenter('left', new Vector3(), anchor) + const right = getHandLocomotionZoneCenter('right', new Vector3(), anchor) + expect(left.toArray()).toEqual([2.8, 1.35, -4.35]) + expect(right.x).toBeCloseTo(3.2) + expect(isInsideHandLocomotionZone(left, 'left', anchor)).toBe(true) + expect(isInsideHandLocomotionZone(left, 'right', anchor)).toBe(false) + }) + + test('applies dead zones, pinch hysteresis, and hand roles', () => { + expect(normalizeHandLocomotionOffset(0.014)).toBe(0) + expect(normalizeHandLocomotionOffset(HAND_ZONE_RADIUS)).toBe(1) + expect(resolveHandPinching(false, 0.03)).toBe(true) + expect(resolveHandPinching(false, 0.035)).toBe(false) + expect(resolveHandPinching(true, 0.04)).toBe(true) + expect(resolveHandPinching(true, 0.045)).toBe(false) + expect(resolveHandControlLabel('left')).toBe('MOVE') + expect(resolveHandControlLabel('right')).toBe('TURN') + }) + + test('scales movement and turning from hand displacement', () => { + expect(resolveHandLocomotionVelocity(HAND_ZONE_RADIUS, 0.1)).toBeCloseTo(0.15) + expect(HAND_TURN_SPEED).toBe(Math.PI / 2) + expect(resolveHandTurnDelta(HAND_ZONE_RADIUS, 1)).toBe(-Math.PI / 2) + }) +}) + +describe('Human comfort and snap turn', () => { + test('scales the vignette with artificial movement speed', () => { + expect(resolveComfortOpacity(0)).toBe(0) + expect(resolveComfortOpacity(0.75)).toBeCloseTo(0.11) + expect(resolveComfortOpacity(3)).toBeCloseTo(0.22) + }) + + test('turns once per stick threshold crossing', () => { + expect(resolveSnapTurnDirection(0.8)).toBe(1) + expect(shouldSnapTurn(0, 1)).toBe(true) + expect(shouldSnapTurn(1, 1)).toBe(false) + expect(resolveSnapTurnDirection(0.1)).toBe(0) + expect(SNAP_TURN_ANGLE).toBe(Math.PI / 6) + }) +}) diff --git a/packages/viewer/src/xr/human-mode/lib/locomotion.test.ts b/packages/viewer/src/xr/human-mode/lib/locomotion.test.ts new file mode 100644 index 0000000000..4b222906fc --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/locomotion.test.ts @@ -0,0 +1,36 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import type { XRControllerState } from '@react-three/xr' +import { Vector3 } from 'three' +import { + getCameraRelativeRight, + getControllerThumbstickAxis, + normalizeMovementVector, + resolveLocomotionDelta, +} from './locomotion' + +function controllerWithAxes(axes: number[]) { + return { inputSource: { gamepad: { axes } } } as unknown as XRControllerState +} + +describe('Human controller locomotion', () => { + test('reads both XR thumbstick axis layouts', () => { + expect(getControllerThumbstickAxis(controllerWithAxes([0.25, -0.5]), 0)).toBe(0.25) + expect(getControllerThumbstickAxis(controllerWithAxes([0, 0, 0.4, -0.6]), 1)).toBe(-0.6) + }) + + test('keeps movement relative to the viewer heading', () => { + const right = new Vector3() + expect(getCameraRelativeRight(new Vector3(0, 0, -1), right).toArray()).toEqual([1, 0, 0]) + expect(getCameraRelativeRight(new Vector3(0, 0, 1), right).toArray()).toEqual([-1, 0, 0]) + }) + + test('caps stalled frames and normalizes diagonal movement', () => { + expect(resolveLocomotionDelta(1)).toBeCloseTo(1 / 30) + expect(resolveLocomotionDelta(1 / 60)).toBeCloseTo(1 / 60) + expect(resolveLocomotionDelta(0)).toBe(0) + expect(normalizeMovementVector(1, 1).x).toBeCloseTo(1 / Math.sqrt(2)) + expect(normalizeMovementVector(1, 1).z).toBeCloseTo(1 / Math.sqrt(2)) + }) +}) diff --git a/packages/viewer/src/xr/human-mode/lib/locomotion.ts b/packages/viewer/src/xr/human-mode/lib/locomotion.ts new file mode 100644 index 0000000000..ce9596184e --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/locomotion.ts @@ -0,0 +1,43 @@ +import type { XRControllerState } from '@react-three/xr' +import { Vector3 as ThreeVector3, type Vector3 } from 'three' + +export const MAX_LOCOMOTION_DELTA = 1 / 30 + +let artificialMovementSpeed = 0 + +export function resolveLocomotionDelta(deltaSeconds: number, maximum = MAX_LOCOMOTION_DELTA) { + if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0 + return Math.min(deltaSeconds, maximum) +} + +export function getControllerThumbstickAxis( + controllerState: XRControllerState | undefined, + axis: 0 | 1, +) { + const axes = controllerState?.inputSource.gamepad?.axes + const axisOffset = axes && axes.length >= 4 ? 2 : 0 + const axisValue = axes?.[axisOffset + axis] + if (Number.isFinite(axisValue)) return axisValue! + + const thumbstick = controllerState?.gamepad?.['xr-standard-thumbstick'] + return axis === 0 ? (thumbstick?.xAxis ?? 0) : (thumbstick?.yAxis ?? 0) +} + +export function normalizeMovementVector(x: number, z: number) { + const length = Math.hypot(x, z) + if (!Number.isFinite(length) || length === 0) return { x: 0, z: 0 } + const scale = Math.min(1, 1 / length) + return { x: x * scale, z: z * scale } +} + +export function getCameraRelativeRight(forward: Vector3, target = new ThreeVector3()) { + return target.set(-forward.z, 0, forward.x).normalize() +} + +export function setArtificialMovementSpeed(speed: number) { + artificialMovementSpeed = Number.isFinite(speed) ? Math.abs(speed) : 0 +} + +export function getArtificialMovementSpeed() { + return artificialMovementSpeed +} diff --git a/packages/viewer/src/xr/human-mode/lib/origin-navigation.ts b/packages/viewer/src/xr/human-mode/lib/origin-navigation.ts new file mode 100644 index 0000000000..f821c6faeb --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/origin-navigation.ts @@ -0,0 +1,30 @@ +import type { Camera, Object3D, Vector3 } from 'three' + +export function translateOrigin( + origin: Object3D, + movement: Vector3, + playerPosition?: Vector3, + resolvedPlayerPosition?: Vector3, +) { + if (playerPosition && resolvedPlayerPosition) { + resolvedPlayerPosition.copy(playerPosition).add(movement) + origin.position.add(resolvedPlayerPosition.sub(playerPosition)) + } else { + origin.position.add(movement) + } + origin.position.y = Math.max(0, origin.position.y) +} + +export function rotateOriginAroundCamera( + origin: Object3D, + camera: Camera, + angle: number, + before: Vector3, + after: Vector3, +) { + camera.getWorldPosition(before) + origin.rotation.y += angle + camera.getWorldPosition(after) + origin.position.x += before.x - after.x + origin.position.z += before.z - after.z +} diff --git a/packages/viewer/src/xr/human-mode/lib/snap-turn.ts b/packages/viewer/src/xr/human-mode/lib/snap-turn.ts new file mode 100644 index 0000000000..c4acc9c8ab --- /dev/null +++ b/packages/viewer/src/xr/human-mode/lib/snap-turn.ts @@ -0,0 +1,14 @@ +import { SNAP_TURN_ANGLE, SNAP_TURN_THRESHOLD } from '../constants/human-mode-constants' + +export { SNAP_TURN_ANGLE, SNAP_TURN_THRESHOLD } + +export function resolveSnapTurnDirection(axis: number, threshold = SNAP_TURN_THRESHOLD) { + if (!Number.isFinite(axis)) return 0 + if (axis >= threshold) return 1 + if (axis <= -threshold) return -1 + return 0 +} + +export function shouldSnapTurn(previousDirection: number, nextDirection: number) { + return previousDirection === 0 && nextDirection !== 0 +} diff --git a/packages/viewer/src/xr/human-mode/store/collision-store.ts b/packages/viewer/src/xr/human-mode/store/collision-store.ts new file mode 100644 index 0000000000..d332f68034 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/store/collision-store.ts @@ -0,0 +1,16 @@ +import type { Mesh } from 'three' +import { resolveCapsuleTranslation } from '../lib/capsule-collision' + +let activeColliders: readonly Mesh[] = [] + +export function setActiveHumanColliders(colliders: readonly Mesh[]) { + activeColliders = colliders +} + +export function resolveHumanCollisionTranslation( + playerPosition: Parameters[1], + movement: Parameters[2], + target: Parameters[3], +) { + return resolveCapsuleTranslation(activeColliders, playerPosition, movement, target) +} diff --git a/packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts b/packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts new file mode 100644 index 0000000000..cf7829cb9d --- /dev/null +++ b/packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts @@ -0,0 +1,60 @@ +import type { Vector3 } from 'three' +import { createStore } from 'zustand/vanilla' + +type HandStateName = 'idle' | 'ready' | 'active' +type Handedness = 'left' | 'right' +type HandJoystickState = { + active: boolean + state: HandStateName + position: [number, number, number] +} + +export const handLocomotionJoystickStore = createStore>( + () => ({ + left: { active: false, state: 'idle', position: [0, 0, 0] }, + right: { active: false, state: 'idle', position: [0, 0, 0] }, + }), +) + +export function showHandLocomotionJoystick(position: Vector3, handedness: Handedness) { + handLocomotionJoystickStore.setState((state) => ({ + ...state, + [handedness]: { + active: true, + state: 'active', + position: [position.x, position.y, position.z], + }, + })) +} + +export function hideHandLocomotionJoystick(handedness?: Handedness) { + if (!handedness) { + handLocomotionJoystickStore.setState({ + left: { active: false, state: 'idle', position: [0, 0, 0] }, + right: { active: false, state: 'idle', position: [0, 0, 0] }, + }) + return + } + handLocomotionJoystickStore.setState((state) => ({ + ...state, + [handedness]: { ...state[handedness], active: false, state: 'idle' }, + })) +} + +export function setHandLocomotionState( + handedness: Handedness, + stateName: HandStateName, + position?: Vector3, +) { + handLocomotionJoystickStore.setState((state) => ({ + ...state, + [handedness]: { + ...state[handedness], + active: stateName === 'active', + state: stateName, + ...(position + ? { position: [position.x, position.y, position.z] as [number, number, number] } + : {}), + }, + })) +} diff --git a/packages/viewer/src/xr/human-mode/store/locomotion-settings.ts b/packages/viewer/src/xr/human-mode/store/locomotion-settings.ts new file mode 100644 index 0000000000..c020611c6d --- /dev/null +++ b/packages/viewer/src/xr/human-mode/store/locomotion-settings.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand' + +export const DEFAULT_LOCOMOTION_SETTINGS = { moveSpeed: 1.5, turnSensitivity: 1 } + +export type LocomotionSettings = typeof DEFAULT_LOCOMOTION_SETTINGS & { + setMoveSpeed(value: number): void + setTurnSensitivity(value: number): void +} + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) + +export const useLocomotionSettings = create((set) => ({ + ...DEFAULT_LOCOMOTION_SETTINGS, + setMoveSpeed: (moveSpeed) => set({ moveSpeed: clamp(moveSpeed, 0.25, 4) }), + setTurnSensitivity: (turnSensitivity) => set({ turnSensitivity: clamp(turnSensitivity, 0.5, 2) }), +})) diff --git a/packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx b/packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx new file mode 100644 index 0000000000..b192299b00 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { MathUtils, type Mesh, type MeshBasicMaterial } from 'three' +import { OVERLAY_LAYER } from '../../../lib/layers' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' +import { resolveComfortOpacity } from '../lib/comfort' +import { getArtificialMovementSpeed } from '../lib/locomotion' + +export function ComfortVignette() { + const camera = useThree((state) => state.camera) + const mode = useXRPlayerMode((state) => state.mode) + const mesh = useRef(null) + const material = useRef(null) + + useEffect(() => { + if (!mesh.current) return + const currentMesh = mesh.current + camera.add(currentMesh) + return () => { + camera.remove(currentMesh) + } + }, [camera]) + + useFrame((_, delta) => { + if (!material.current) return + const target = + mode === XR_PLAYER_MODES.HUMAN ? resolveComfortOpacity(getArtificialMovementSpeed()) : 0 + material.current.opacity = MathUtils.damp(material.current.opacity, target, 18, delta) + }) + + return ( + + + + + ) +} diff --git a/packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx b/packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx new file mode 100644 index 0000000000..ed42e04a89 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx @@ -0,0 +1,178 @@ +'use client' + +import { Text } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { useXR } from '@react-three/xr' +import { useMemo, useRef } from 'react' +import { DoubleSide, Euler, type Group, Quaternion, Vector3 } from 'three' +import { useStore } from 'zustand' +import { OVERLAY_LAYER } from '../../../lib/layers' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' +import { HAND_ZONE_RADIUS } from '../constants/human-mode-constants' +import { + getHandLocomotionZoneCenter, + resolveHandControlLabel, + resolveHandJoystickArrowRotations, +} from '../lib/hand-locomotion' +import { handLocomotionJoystickStore } from '../store/hand-locomotion-joystick' + +const ignoreRaycast = () => null +const JOYSTICK_RADIUS = 0.05 +const JOYSTICK_ARROW_DISTANCE = 0.033 +const JOYSTICK_COLOR = '#03070c' +const ACTIVATION_RING_WIDTH = 0.002 + +function HandLocomotionJoystick({ handedness }: { handedness: 'left' | 'right' }) { + const group = useRef(null) + const origin = useXR((state) => state.origin) + const parentInverse = useMemo(() => new Quaternion(), []) + const groundRotation = useMemo( + () => new Quaternion().setFromEuler(new Euler(-Math.PI / 2, 0, 0)), + [], + ) + const control = useStore(handLocomotionJoystickStore, (state) => state[handedness]) + const arrows = resolveHandJoystickArrowRotations(handedness) + + useFrame(() => { + if (!group.current || !origin) return + parentInverse.copy(origin.quaternion).invert() + group.current.quaternion.copy(parentInverse).multiply(groundRotation) + }) + + return ( + + + + + + {arrows.map((rotation) => ( + + + + + + + ))} + + ) +} + +function HandActivationZone({ handedness }: { handedness: 'left' | 'right' }) { + const group = useRef(null) + const camera = useThree((state) => state.camera) + const origin = useXR((state) => state.origin) + const cameraWorld = useMemo(() => new Vector3(), []) + const anchor = useMemo(() => new Vector3(), []) + const control = useStore(handLocomotionJoystickStore, (state) => state[handedness]) + + useFrame(() => { + if (!group.current || !origin) return + camera.getWorldPosition(cameraWorld) + anchor.copy(cameraWorld) + origin.worldToLocal(anchor) + getHandLocomotionZoneCenter(handedness, group.current.position, anchor) + group.current.quaternion.copy(origin.quaternion).invert() + }) + + const color = + control.state === 'active' ? '#38bdf8' : control.state === 'ready' ? '#fbbf24' : '#64748b' + return ( + + + + + + + + + + + {resolveHandControlLabel(handedness)} + + + ) +} + +export function HandLocomotionZone() { + const mode = useXRPlayerMode((state) => state.mode) + const hands = useXR((state) => + state.inputSourceStates + .filter(({ type }) => type === 'hand') + .map(({ inputSource }) => inputSource.handedness), + ) + if (mode !== XR_PLAYER_MODES.HUMAN) return null + return ( + <> + {hands.includes('left') && ( + <> + + + + )} + {hands.includes('right') && ( + <> + + + + )} + + ) +} diff --git a/packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx b/packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx new file mode 100644 index 0000000000..995b803ee7 --- /dev/null +++ b/packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx @@ -0,0 +1,19 @@ +'use client' + +import type { RefObject } from 'react' +import type { Object3D } from 'three' +import { ControllerLocomotion } from '../input/controller-locomotion' +import { HumanCollisionRig } from '../input/human-collision-rig' +import { ComfortVignette } from './comfort-vignette' +import { HandLocomotionZone } from './hand-locomotion-zone' + +export function HumanModeControls({ sceneRootRef }: { sceneRootRef: RefObject }) { + return ( + + + + + + + ) +} diff --git a/packages/viewer/src/xr/input-visuals.tsx b/packages/viewer/src/xr/input-visuals.tsx new file mode 100644 index 0000000000..980b1356d4 --- /dev/null +++ b/packages/viewer/src/xr/input-visuals.tsx @@ -0,0 +1,174 @@ +'use client' + +import { + DefaultXRController, + DefaultXRHand, + useXRInputSourceStateContext, + XRSpace, +} from '@react-three/xr' +import { OVERLAY_LAYER } from '../lib/layers' + +const HAND_JOINTS: readonly XRHandJoint[] = [ + 'wrist', + 'thumb-metacarpal', + 'thumb-phalanx-proximal', + 'thumb-phalanx-distal', + 'thumb-tip', + 'index-finger-metacarpal', + 'index-finger-phalanx-proximal', + 'index-finger-phalanx-intermediate', + 'index-finger-phalanx-distal', + 'index-finger-tip', + 'middle-finger-metacarpal', + 'middle-finger-phalanx-proximal', + 'middle-finger-phalanx-intermediate', + 'middle-finger-phalanx-distal', + 'middle-finger-tip', + 'ring-finger-metacarpal', + 'ring-finger-phalanx-proximal', + 'ring-finger-phalanx-intermediate', + 'ring-finger-phalanx-distal', + 'ring-finger-tip', + 'pinky-finger-metacarpal', + 'pinky-finger-phalanx-proximal', + 'pinky-finger-phalanx-intermediate', + 'pinky-finger-phalanx-distal', + 'pinky-finger-tip', +] + +export function XRControllerVisual() { + const state = useXRInputSourceStateContext('controller') + const accent = state.inputSource.handedness === 'left' ? '#38bdf8' : '#fb923c' + + return ( + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export function XRHandVisual() { + const state = useXRInputSourceStateContext('hand') + const color = state.inputSource.handedness === 'left' ? '#bae6fd' : '#fed7aa' + const side = state.inputSource.handedness === 'left' ? -1 : 1 + + return ( + <> + + + + + + {[-0.045, -0.015, 0.015, 0.045].map((x, index) => ( + + + + + ))} + + + + + + {HAND_JOINTS.map((joint) => ( + + + + + + + ))} + + ) +} + +export function VisibleXRController() { + return ( + <> + + + + ) +} + +export function VisibleXRHand() { + return ( + <> + + + + ) +} + +export { HAND_JOINTS } diff --git a/packages/viewer/src/xr/mode-switching/index.ts b/packages/viewer/src/xr/mode-switching/index.ts new file mode 100644 index 0000000000..9d8e8463d3 --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/index.ts @@ -0,0 +1,7 @@ +export { + toggleXRPlayerMode, + useXRPlayerMode, + XR_PLAYER_MODES, + type XRPlayerMode, +} from './store/player-mode' +export { PlayerModeScene } from './ui/player-mode-scene' diff --git a/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts b/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts new file mode 100644 index 0000000000..6311e4a9da --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts @@ -0,0 +1,34 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { Object3D, Vector3 } from 'three' +import { + captureGodSceneTransform, + resetSceneForHumanScale, + resolveXRHumanOriginTarget, + restoreGodSceneTransform, +} from './scene-scale-transition' + +describe('God and Human scene transition', () => { + test('restores the God transform after world-scale Human mode', () => { + const root = new Object3D() + root.position.set(2, 3, 4) + root.rotation.set(0, 0.5, 0) + root.scale.setScalar(2) + const transform = captureGodSceneTransform(root) + + resetSceneForHumanScale(root) + expect(root.position.toArray()).toEqual([0, 0, 0]) + expect(root.scale.toArray()).toEqual([1, 1, 1]) + + expect(restoreGodSceneTransform(root, transform)).toBe(true) + expect(root.position.toArray()).toEqual([2, 3, 4]) + expect(root.rotation.y).toBeCloseTo(0.5) + expect(root.scale.toArray()).toEqual([2, 2, 2]) + }) + + test('places the tracked viewer over the selected Human point', () => { + const target = resolveXRHumanOriginTarget(new Vector3(4, 0, -2), new Vector3(0.25, 1.65, -0.5)) + expect(target.toArray()).toEqual([3.75, 0, -1.5]) + }) +}) diff --git a/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts b/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts new file mode 100644 index 0000000000..2993ebaaae --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts @@ -0,0 +1,72 @@ +import { type Euler, type Object3D, Vector3 } from 'three' + +export type SceneTransform = { + position: Vector3 + rotation: Euler + scale: Vector3 +} + +export function captureGodSceneTransform(sceneRoot: Object3D | null): SceneTransform | null { + if (!sceneRoot) return null + return { + position: sceneRoot.position.clone(), + rotation: sceneRoot.rotation.clone(), + scale: sceneRoot.scale.clone(), + } +} + +export function resetSceneForHumanScale(sceneRoot: Object3D | null) { + if (!sceneRoot) return + sceneRoot.position.set(0, 0, 0) + sceneRoot.rotation.set(0, 0, 0) + sceneRoot.scale.setScalar(1) + sceneRoot.updateWorldMatrix(true, false) +} + +export function restoreGodSceneTransform( + sceneRoot: Object3D | null, + transform: SceneTransform | null, +) { + if (!sceneRoot || !transform) return false + sceneRoot.position.copy(transform.position) + sceneRoot.rotation.copy(transform.rotation) + sceneRoot.scale.copy(transform.scale) + sceneRoot.updateWorldMatrix(true, false) + return true +} + +export function resolveHumanPointInScene( + sceneRoot: Object3D | null, + worldPosition: Vector3, + worldDirection: Vector3, + target = new Vector3(), + maximumDistance = 5, +) { + if (!sceneRoot) return target.set(worldPosition.x, 0, worldPosition.z) + + sceneRoot.updateWorldMatrix(true, false) + const inverseSceneMatrix = sceneRoot.matrixWorld.clone().invert() + const localPosition = worldPosition.clone().applyMatrix4(inverseSceneMatrix) + const localDirection = worldDirection.clone().transformDirection(inverseSceneMatrix) + const worldScale = sceneRoot.getWorldScale(new Vector3()) + const minimumScale = Math.max( + 1e-6, + Math.min(Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z)), + ) + const distance = + Math.abs(localDirection.y) > 0.05 ? -localPosition.y / localDirection.y : maximumDistance + const clampedDistance = Math.min(maximumDistance / minimumScale, Math.max(1, distance)) + return target.set( + localPosition.x + localDirection.x * clampedDistance, + 0, + localPosition.z + localDirection.z * clampedDistance, + ) +} + +export function resolveXRHumanOriginTarget( + humanPoint: Vector3, + viewerLocalPosition: Vector3, + target = new Vector3(), +) { + return target.set(humanPoint.x - viewerLocalPosition.x, 0, humanPoint.z - viewerLocalPosition.z) +} diff --git a/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts b/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts new file mode 100644 index 0000000000..57a60af0c8 --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts @@ -0,0 +1,29 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { Vector3 } from 'three' +import { + advanceThumbModeGesture, + areThumbTipsTouching, + THUMB_TOUCH_TRIGGER_SECONDS, +} from './thumb-mode-gesture' + +describe('hand-tracked player mode switching', () => { + test('recognizes two visible touching thumb tips', () => { + expect( + areThumbTipsTouching( + { visible: true, position: new Vector3(0, 0, 0) }, + { visible: true, position: new Vector3(0.02, 0, 0) }, + ), + ).toBe(true) + }) + + test('toggles once after a held touch and rearms after release', () => { + const state = { elapsed: 0, triggered: false } + expect(advanceThumbModeGesture(state, true, THUMB_TOUCH_TRIGGER_SECONDS - 0.01)).toBe(false) + expect(advanceThumbModeGesture(state, true, 0.01)).toBe(true) + expect(advanceThumbModeGesture(state, true, 1)).toBe(false) + advanceThumbModeGesture(state, false, 0.016) + expect(advanceThumbModeGesture(state, true, THUMB_TOUCH_TRIGGER_SECONDS)).toBe(true) + }) +}) diff --git a/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts b/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts new file mode 100644 index 0000000000..dc3a5a39dc --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts @@ -0,0 +1,42 @@ +import type { Vector3 } from 'three' + +export const THUMB_TOUCH_TRIGGER_SECONDS = 0.8 +export const THUMB_TOUCH_DISTANCE = 0.045 + +type TrackedThumb = { + position: Vector3 + visible: boolean +} + +export type ThumbModeGestureState = { + elapsed: number + triggered: boolean +} + +export function areThumbTipsTouching( + left: TrackedThumb | null, + right: TrackedThumb | null, + maximumDistance = THUMB_TOUCH_DISTANCE, +) { + return ( + Boolean(left?.visible && right?.visible) && + left!.position.distanceTo(right!.position) <= maximumDistance + ) +} + +export function advanceThumbModeGesture( + state: ThumbModeGestureState, + touching: boolean, + deltaSeconds: number, +) { + if (!touching) { + state.elapsed = 0 + state.triggered = false + return false + } + if (state.triggered) return false + state.elapsed += Math.max(0, deltaSeconds) + if (state.elapsed < THUMB_TOUCH_TRIGGER_SECONDS) return false + state.triggered = true + return true +} diff --git a/packages/viewer/src/xr/mode-switching/store/player-mode.test.ts b/packages/viewer/src/xr/mode-switching/store/player-mode.test.ts new file mode 100644 index 0000000000..2ce7392861 --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/store/player-mode.test.ts @@ -0,0 +1,15 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { beforeEach, describe, expect, test } from 'bun:test' +import { toggleXRPlayerMode, useXRPlayerMode, XR_PLAYER_MODES } from './player-mode' + +describe('XR player mode', () => { + beforeEach(() => useXRPlayerMode.getState().setMode(XR_PLAYER_MODES.GOD)) + + test('toggles between God and Human mode', () => { + toggleXRPlayerMode() + expect(useXRPlayerMode.getState().mode).toBe(XR_PLAYER_MODES.HUMAN) + toggleXRPlayerMode() + expect(useXRPlayerMode.getState().mode).toBe(XR_PLAYER_MODES.GOD) + }) +}) diff --git a/packages/viewer/src/xr/mode-switching/store/player-mode.ts b/packages/viewer/src/xr/mode-switching/store/player-mode.ts new file mode 100644 index 0000000000..fc7e0f3e84 --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/store/player-mode.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' + +export const XR_PLAYER_MODES = { + GOD: 'god', + HUMAN: 'human', +} as const + +export type XRPlayerMode = (typeof XR_PLAYER_MODES)[keyof typeof XR_PLAYER_MODES] + +type XRPlayerModeState = { + mode: XRPlayerMode + setMode(mode: XRPlayerMode): void + toggle(): void +} + +export const useXRPlayerMode = create((set) => ({ + mode: XR_PLAYER_MODES.GOD, + setMode: (mode) => set({ mode }), + toggle: () => + set((state) => ({ + mode: state.mode === XR_PLAYER_MODES.GOD ? XR_PLAYER_MODES.HUMAN : XR_PLAYER_MODES.GOD, + })), +})) + +export function toggleXRPlayerMode() { + useXRPlayerMode.getState().toggle() +} diff --git a/packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx b/packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx new file mode 100644 index 0000000000..1527e13967 --- /dev/null +++ b/packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx @@ -0,0 +1,300 @@ +'use client' + +import { useFrame, useThree } from '@react-three/fiber' +import { + CombinedPointer, + DefaultXRController, + DefaultXRHand, + useXR, + useXRInputSourceState, + useXRInputSourceStateContext, + type XRControllerState, + XRSpace, +} from '@react-three/xr' +import { type ComponentType, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react' +import { Euler, type Group, type Object3D, Vector3 } from 'three' +import { DistanceAwareRayPointer } from '../../distance-aware-ray-pointer' +import { GOD_ORIGIN_POSITION, GOD_ORIGIN_ROTATION } from '../../god-mode' +import { GodModeHandControls } from '../../god-mode/input/god-mode-hand-controls' +import { GodModeControls } from '../../god-mode/ui/god-mode-controls' +import { HumanModeHandControls } from '../../human-mode/input/hand-locomotion' +import { pulseInputSource } from '../../human-mode/lib/haptics' +import { HumanModeControls } from '../../human-mode/ui/human-mode-controls' +import { + VisibleXRController, + VisibleXRHand, + XRControllerVisual, + XRHandVisual, +} from '../../input-visuals' +import { DISTANCE_AWARE_RAY_POINTER_OPTIONS } from '../../pointer-cursor' +import { isR3FPointerTarget } from '../../pointer-filter' +import type { ViewerXRStore } from '../../store' +import { + captureGodSceneTransform, + resetSceneForHumanScale, + resolveHumanPointInScene, + resolveXRHumanOriginTarget, + restoreGodSceneTransform, + type SceneTransform, +} from '../lib/scene-scale-transition' +import { + advanceThumbModeGesture, + areThumbTipsTouching, + type ThumbModeGestureState, +} from '../lib/thumb-mode-gesture' +import { useXRPlayerMode, XR_PLAYER_MODES } from '../store/player-mode' + +function PlayerModeDefaultHand() { + return ( + <> + + + + + + + ) +} + +function PlayerModeHandInput() { + return ( + <> + + + + + + ) +} + +type InputSourceOverlay = ComponentType<{ type: 'controller' | 'hand' }> + +function createPlayerModeHandInput(InputSourceOverlay: InputSourceOverlay) { + return function PlayerModeHandInputWithOverlay() { + return ( + <> + + + + + + + ) + } +} + +function PlayerModeDefaultController() { + return ( + <> + + + + + + + ) +} + +function createPlayerModeControllerInput(InputSourceOverlay?: InputSourceOverlay) { + return function PlayerModeControllerInputWithOverlay() { + return ( + <> + + {InputSourceOverlay && } + + ) + } +} + +type Handedness = 'left' | 'right' + +const thumbObjects: Record = { left: null, right: null } + +function PlayerModeHandThumbInput() { + const state = useXRInputSourceStateContext('hand') + const handedness = state.inputSource.handedness + const setThumbObject = useCallback( + (object: Object3D | null) => { + if (handedness === 'left' || handedness === 'right') thumbObjects[handedness] = object + }, + [handedness], + ) + + return +} + +function PlayerModeHandToggle({ disabled = false }: { disabled?: boolean }) { + const leftHand = useXRInputSourceState('hand', 'left') + const rightHand = useXRInputSourceState('hand', 'right') + const leftThumb = useRef({ position: new Vector3(), visible: false }) + const rightThumb = useRef({ position: new Vector3(), visible: false }) + const gesture = useRef({ elapsed: 0, triggered: false }) + const selectionBlockedGesture = useRef(false) + + useFrame((_, delta) => { + if (disabled) return + const leftObject = thumbObjects.left + const rightObject = thumbObjects.right + leftThumb.current.visible = leftObject?.visible === true + rightThumb.current.visible = rightObject?.visible === true + if (leftThumb.current.visible) leftObject!.getWorldPosition(leftThumb.current.position) + if (rightThumb.current.visible) rightObject!.getWorldPosition(rightThumb.current.position) + + const selecting = + leftHand?.inputSource.gamepad?.buttons[0]?.pressed === true || + rightHand?.inputSource.gamepad?.buttons[0]?.pressed === true + const touching = areThumbTipsTouching(leftThumb.current, rightThumb.current) + if (selecting) selectionBlockedGesture.current = true + else if (!touching) selectionBlockedGesture.current = false + if ( + advanceThumbModeGesture(gesture.current, touching && !selectionBlockedGesture.current, delta) + ) { + useXRPlayerMode.getState().toggle() + } + }) + + return null +} + +function isModeButtonPressed(controller: XRControllerState | undefined) { + if (controller?.gamepad?.['y-button']?.state === 'pressed') return true + return controller?.inputSource.gamepad?.buttons[5]?.pressed === true +} + +function PlayerModeControllerToggle() { + const leftController = useXRInputSourceState('controller', 'left') + const pressed = useRef(false) + + useFrame(() => { + const nextPressed = isModeButtonPressed(leftController) + if (!pressed.current && nextPressed) { + useXRPlayerMode.getState().toggle() + pulseInputSource(leftController?.inputSource, 0.25, 35) + } + pressed.current = nextPressed + }) + return null +} + +function PlayerModeRig({ sceneRootRef }: { sceneRootRef: React.RefObject }) { + const camera = useThree((state) => state.camera) + const origin = useXR((state) => state.origin) + const mode = useXRPlayerMode((state) => state.mode) + const previousMode = useRef(mode) + const transitionActive = useRef(false) + const godTransform = useRef(null) + const cameraWorldPosition = useRef(new Vector3()) + const cameraDirection = useRef(new Vector3()) + const cameraLocalPosition = useRef(new Vector3()) + const humanPoint = useRef(new Vector3()) + const targetPosition = useRef(new Vector3()) + const targetRotation = useRef(new Euler()) + + useFrame((_, delta) => { + const root = sceneRootRef.current + if (!root || !origin) return + + if (mode !== previousMode.current) { + if (mode === XR_PLAYER_MODES.HUMAN) { + godTransform.current = captureGodSceneTransform(root) + camera.getWorldPosition(cameraWorldPosition.current) + camera.getWorldDirection(cameraDirection.current) + resolveHumanPointInScene( + root, + cameraWorldPosition.current, + cameraDirection.current, + humanPoint.current, + ) + cameraLocalPosition.current.copy(cameraWorldPosition.current) + origin.worldToLocal(cameraLocalPosition.current) + resolveXRHumanOriginTarget( + humanPoint.current, + cameraLocalPosition.current, + targetPosition.current, + ) + resetSceneForHumanScale(root) + targetRotation.current.set(0, 0, 0) + } else { + restoreGodSceneTransform(root, godTransform.current) + targetPosition.current.copy(GOD_ORIGIN_POSITION) + targetRotation.current.copy(GOD_ORIGIN_ROTATION) + } + previousMode.current = mode + transitionActive.current = true + } + + if (!transitionActive.current) return + + const blend = 1 - Math.exp(-delta * 8) + origin.position.lerp(targetPosition.current, blend) + origin.rotation.x += (targetRotation.current.x - origin.rotation.x) * blend + origin.rotation.y += (targetRotation.current.y - origin.rotation.y) * blend + origin.rotation.z += (targetRotation.current.z - origin.rotation.z) * blend + if (origin.position.distanceTo(targetPosition.current) < 0.002) { + origin.position.copy(targetPosition.current) + origin.rotation.copy(targetRotation.current) + transitionActive.current = false + } + }) + + return null +} + +export function PlayerModeScene({ + children, + inputSourceOverlay, + store, +}: { + children: ReactNode + inputSourceOverlay?: InputSourceOverlay + store: ViewerXRStore +}) { + const sceneRootRef = useRef(null) + const HandInput = useMemo( + () => + inputSourceOverlay ? createPlayerModeHandInput(inputSourceOverlay) : PlayerModeHandInput, + [inputSourceOverlay], + ) + const ControllerInput = useMemo( + () => createPlayerModeControllerInput(inputSourceOverlay), + [inputSourceOverlay], + ) + + useEffect(() => { + useXRPlayerMode.getState().setMode(XR_PLAYER_MODES.GOD) + store.setHand(HandInput) + store.setController(ControllerInput) + return () => { + store.setHand(VisibleXRHand) + store.setController(VisibleXRController) + useXRPlayerMode.getState().setMode(XR_PLAYER_MODES.GOD) + } + }, [ControllerInput, HandInput, store]) + + return ( + <> + + + + + + + {children} + + + ) +} diff --git a/packages/viewer/src/xr/pointer-cursor.test.ts b/packages/viewer/src/xr/pointer-cursor.test.ts new file mode 100644 index 0000000000..4b673d940f --- /dev/null +++ b/packages/viewer/src/xr/pointer-cursor.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { + DISTANCE_AWARE_RAY_POINTER_OPTIONS, + POINTER_CURSOR_INNER_RADIUS, + POINTER_CURSOR_MAX_SIZE, + POINTER_CURSOR_MIN_SIZE, + POINTER_CURSOR_OUTER_RADIUS, + resolvePointerCursorSize, +} from './pointer-cursor' +import { PointerRingMaterial } from './pointer-ring-material' + +describe('XR pointer cursor styling', () => { + test('uses a real ring geometry for the intersection cursor', () => { + expect(POINTER_CURSOR_INNER_RADIUS).toBe(0.32) + expect(POINTER_CURSOR_OUTER_RADIUS).toBe(0.5) + expect(DISTANCE_AWARE_RAY_POINTER_OPTIONS.cursorModel.cursorOffset).toBeGreaterThan(0) + expect(PointerRingMaterial).toBeDefined() + }) +}) + +describe('resolvePointerCursorSize', () => { + test('grows the cursor as the ray intersection gets farther away', () => { + const near = resolvePointerCursorSize(0.2) + const medium = resolvePointerCursorSize(1) + const far = resolvePointerCursorSize(4) + + expect(near).toBeLessThan(medium) + expect(medium).toBeLessThan(far) + }) + + test('clamps the cursor to visible minimum and maximum sizes', () => { + expect(resolvePointerCursorSize(0)).toBe(POINTER_CURSOR_MIN_SIZE) + expect(resolvePointerCursorSize(Number.NaN)).toBe(POINTER_CURSOR_MIN_SIZE) + expect(resolvePointerCursorSize(100)).toBe(POINTER_CURSOR_MAX_SIZE) + }) +}) diff --git a/packages/viewer/src/xr/pointer-cursor.ts b/packages/viewer/src/xr/pointer-cursor.ts new file mode 100644 index 0000000000..4c435149d3 --- /dev/null +++ b/packages/viewer/src/xr/pointer-cursor.ts @@ -0,0 +1,24 @@ +import type { DefaultXRInputSourceRayPointerOptions } from '@react-three/xr' + +export const POINTER_CURSOR_MIN_SIZE = 0.012 +export const POINTER_CURSOR_MAX_SIZE = 0.14 +export const POINTER_CURSOR_INNER_RADIUS = 0.32 +export const POINTER_CURSOR_OUTER_RADIUS = 0.5 + +export function resolvePointerCursorSize(distance: number): number { + if (!Number.isFinite(distance)) return POINTER_CURSOR_MIN_SIZE + return Math.max(POINTER_CURSOR_MIN_SIZE, Math.min(POINTER_CURSOR_MAX_SIZE, distance * 0.06)) +} + +export const DISTANCE_AWARE_RAY_POINTER_OPTIONS = { + clickThresholdMs: Number.POSITIVE_INFINITY, + minDistance: 0, + rayModel: { + color: '#7dd3fc', + }, + cursorModel: { + color: '#7dd3fc', + opacity: 0.9, + cursorOffset: 0.008, + }, +} satisfies DefaultXRInputSourceRayPointerOptions diff --git a/packages/viewer/src/xr/pointer-filter.test.ts b/packages/viewer/src/xr/pointer-filter.test.ts new file mode 100644 index 0000000000..dd341c5380 --- /dev/null +++ b/packages/viewer/src/xr/pointer-filter.test.ts @@ -0,0 +1,57 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { Group, Mesh } from 'three' +import { isDirectR3FPointerTarget, isR3FPointerTarget } from './pointer-filter' + +describe('isDirectR3FPointerTarget', () => { + test('keeps an explicit collision mesh ahead of its passive rendered sibling', () => { + const passiveWallBody = new Mesh() + const wallCollisionMesh = new Mesh() + const interactiveLevelWrapper = new Group() + + ;(interactiveLevelWrapper as Group & { __r3f: { eventCount: number } }).__r3f = { + eventCount: 6, + } + interactiveLevelWrapper.add(passiveWallBody, wallCollisionMesh) + ;(wallCollisionMesh as Mesh & { __r3f: { eventCount: number } }).__r3f = { eventCount: 6 } + + expect(isDirectR3FPointerTarget(passiveWallBody)).toBe(false) + expect(isDirectR3FPointerTarget(wallCollisionMesh)).toBe(true) + expect(isR3FPointerTarget(passiveWallBody)).toBe(false) + expect(isR3FPointerTarget(wallCollisionMesh)).toBe(true) + }) + + test('keeps an explicit collision child ahead of its passive rendered parent', () => { + const passiveWallBody = new Mesh() + const wallCollisionMesh = new Mesh() + const interactiveWrapper = new Group() + + ;(interactiveWrapper as Group & { __r3f: { eventCount: number } }).__r3f = { + eventCount: 6, + } + ;(wallCollisionMesh as Mesh & { __r3f: { eventCount: number } }).__r3f = { eventCount: 6 } + passiveWallBody.add(wallCollisionMesh) + interactiveWrapper.add(passiveWallBody) + + expect(isR3FPointerTarget(passiveWallBody)).toBe(false) + expect(isR3FPointerTarget(wallCollisionMesh)).toBe(true) + }) + + test('inherits pointer handlers for nested imported meshes', () => { + const interactiveItemWrapper = new Group() + const importedGroup = new Group() + const importedMesh = new Mesh() + ;(interactiveItemWrapper as Group & { __r3f: { eventCount: number } }).__r3f = { + eventCount: 6, + } + interactiveItemWrapper.add(importedGroup) + importedGroup.add(importedMesh) + + expect(isR3FPointerTarget(importedMesh)).toBe(true) + }) + + test('rejects geometry with no eventful ancestor', () => { + expect(isR3FPointerTarget(new Mesh())).toBe(false) + }) +}) diff --git a/packages/viewer/src/xr/pointer-filter.ts b/packages/viewer/src/xr/pointer-filter.ts new file mode 100644 index 0000000000..b9b9073b72 --- /dev/null +++ b/packages/viewer/src/xr/pointer-filter.ts @@ -0,0 +1,19 @@ +import type { Object3D } from 'three' + +type R3FPointerObject = Object3D & { + __r3f?: { eventCount?: number } +} + +export function isDirectR3FPointerTarget(object: Object3D): boolean { + return ((object as R3FPointerObject).__r3f?.eventCount ?? 0) > 0 +} + +export function isR3FPointerTarget(object: Object3D): boolean { + let current: Object3D | null = object + while (current) { + if (current.children.some(isDirectR3FPointerTarget)) return false + if (isDirectR3FPointerTarget(current)) return true + current = current.parent + } + return false +} diff --git a/packages/viewer/src/xr/pointer-ring-material.ts b/packages/viewer/src/xr/pointer-ring-material.ts new file mode 100644 index 0000000000..31a8d90b0d --- /dev/null +++ b/packages/viewer/src/xr/pointer-ring-material.ts @@ -0,0 +1,12 @@ +import { DoubleSide, MeshBasicMaterial } from 'three' + +export class PointerRingMaterial extends MeshBasicMaterial { + constructor() { + super({ + transparent: true, + toneMapped: false, + depthWrite: false, + side: DoubleSide, + }) + } +} diff --git a/packages/viewer/src/xr/presentation-background.test.ts b/packages/viewer/src/xr/presentation-background.test.ts new file mode 100644 index 0000000000..b7e33fab8f --- /dev/null +++ b/packages/viewer/src/xr/presentation-background.test.ts @@ -0,0 +1,10 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { immersiveXRBackgroundColor } from './presentation-background' + +describe('immersive XR background', () => { + test('uses the scene background instead of flattening the blue zenith color', () => { + expect(immersiveXRBackgroundColor('studio')).toBe('#fbfbfa') + }) +}) diff --git a/packages/viewer/src/xr/presentation-background.ts b/packages/viewer/src/xr/presentation-background.ts new file mode 100644 index 0000000000..79c4aa2e66 --- /dev/null +++ b/packages/viewer/src/xr/presentation-background.ts @@ -0,0 +1,5 @@ +import { getSceneTheme } from '../lib/scene-themes' + +export function immersiveXRBackgroundColor(sceneTheme: string) { + return getSceneTheme(sceneTheme).background +} diff --git a/packages/viewer/src/xr/presentation-context.tsx b/packages/viewer/src/xr/presentation-context.tsx new file mode 100644 index 0000000000..968aec6bcc --- /dev/null +++ b/packages/viewer/src/xr/presentation-context.tsx @@ -0,0 +1,23 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' + +const ImmersiveXRPresentationContext = createContext(false) + +export function ImmersiveXRPresentationProvider({ + children, + enabled, +}: { + children: ReactNode + enabled: boolean +}) { + return ( + + {children} + + ) +} + +export function useImmersiveXRPresentation() { + return useContext(ImmersiveXRPresentationContext) +} diff --git a/packages/viewer/src/xr/session-root.tsx b/packages/viewer/src/xr/session-root.tsx new file mode 100644 index 0000000000..ee958a5459 --- /dev/null +++ b/packages/viewer/src/xr/session-root.tsx @@ -0,0 +1,168 @@ +'use client' + +import { advance, useStore, useThree } from '@react-three/fiber' +import { XR, XROrigin } from '@react-three/xr' +import type { ReactNode } from 'react' +import { useEffect, useRef } from 'react' +import FrameLimiter from '../components/viewer/frame-limiter' +import { applyViewerCameraClipping, viewerCameraClipping } from '../components/viewer/viewer-camera' +import { + advanceXRFrameWithoutDesktopRender, + ownsXRFrameLoopBinding, + renderImmersiveXRFrame, + shouldPauseFrameLimiterForXR, + stopXRFrameLoop, + takeOverXRFrameLoop, + type XRFrameLoopRenderer, +} from './frame-loop' +import type { ViewerXRStore } from './store' + +function XRFrameLimiter({ + fps, + paused, + session, +}: { + fps: number + paused: boolean + session?: XRSession +}) { + return +} + +function configureWebGLXRBaseLayer(manager: { [key: string]: unknown }) { + // Three prefers XRProjectionLayer whenever a partial XRWebGLBinding exists. + // IWER exposes that binding but drives input frames from XRWebGLLayer, so + // projection-layer selection leaves the session without a base layer. + if ('_supportsLayers' in manager) manager._supportsLayers = false +} + +function XRSessionBinding({ session, store }: { session?: XRSession; store: ViewerXRStore }) { + const renderer = useThree((state) => state.gl) + const r3fXR = useThree((state) => state.xr) + const rootStore = useStore() + const activeBinding = useRef(null) + + useEffect(() => { + const manager = renderer.xr + if (!session) return + + let cancelled = false + let restoreFrameLoop: (() => void) | undefined + let resyncInputsOnNextFrame = false + const binding = Symbol('xr-session-binding') + activeBinding.current = binding + const state = rootStore.getState() + const baseCamera = state.camera + + const attachSession = async () => { + // Attach the session before starting the renderer-owned loop. IWER + // publishes input sources on its first frame; starting the loop first + // can race @react-three/xr's session synchronization and leave the + // store with a session but no controllers or hands. + r3fXR?.disconnect() + configureWebGLXRBaseLayer(manager as unknown as { [key: string]: unknown }) + const restore = await takeOverXRFrameLoop( + renderer as unknown as XRFrameLoopRenderer, + r3fXR, + (time, frame) => { + if (!frame) return + if (resyncInputsOnNextFrame) { + resyncInputsOnNextFrame = false + const xrState = store.getState() + if ( + xrState.session !== session || + (xrState.inputSourceStates.length === 0 && session.inputSources.length > 0) + ) { + // IWER publishes its initial controllers on the first immersive + // frame. Rebinding here lets the XR store consume the current + // session.inputSources even when that first change event raced + // the renderer's sessionstart event. + manager.dispatchEvent({ type: 'sessionstart' }) + } + } + const frameState = rootStore.getState() + advanceXRFrameWithoutDesktopRender(frameState, () => { + advance(time, true, frameState, frame) + }) + renderImmersiveXRFrame(renderer, frameState.scene, baseCamera) + }, + { + dpr: state.viewport.dpr, + height: state.size.height, + width: state.size.width, + }, + ) + restoreFrameLoop = restore + if (cancelled) { + // React Strict Mode can begin the replacement binding before this + // async setup settles. Only restore when this cancelled setup still + // owns the renderer; otherwise it would erase the newer frame loop. + if (ownsXRFrameLoopBinding(activeBinding.current, binding)) restore() + return + } + + if (manager.getSession() !== session) await manager.setSession(session) + session.addEventListener( + 'end', + () => stopXRFrameLoop(renderer as unknown as XRFrameLoopRenderer), + { once: true }, + ) + applyViewerCameraClipping(manager.getCamera(), true) + const clipping = viewerCameraClipping(true) + session.updateRenderState({ + baseLayer: manager.getBaseLayer() as XRWebGLLayer | undefined, + depthFar: clipping.far, + depthNear: clipping.near, + }) + + // The WebGPU renderer's WebGL backend can omit Three's sessionstart event, + // which leaves @react-three/xr unaware of controllers and hands. + if (store.getState().session !== session) { + manager.dispatchEvent({ type: 'sessionstart' }) + } + resyncInputsOnNextFrame = true + + if (cancelled) { + restore() + return + } + } + + void attachSession().catch((error: unknown) => { + console.error('[viewer] Could not attach the WebXR session', error) + void session.end().catch(() => undefined) + }) + + return () => { + cancelled = true + if (ownsXRFrameLoopBinding(activeBinding.current, binding)) restoreFrameLoop?.() + } + }, [renderer, r3fXR, rootStore, session, store]) + + return null +} + +export function ViewerXRSessionRoot({ + children, + fps, + originPosition, + paused, + session, + store, +}: { + children: ReactNode + fps: number + originPosition?: [number, number, number] + paused: boolean + session?: XRSession + store: ViewerXRStore +}) { + return ( + + + + + {children} + + ) +} diff --git a/packages/viewer/src/xr/store.test.ts b/packages/viewer/src/xr/store.test.ts new file mode 100644 index 0000000000..5a07345724 --- /dev/null +++ b/packages/viewer/src/xr/store.test.ts @@ -0,0 +1,22 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { HAND_JOINTS, VisibleXRController, VisibleXRHand } from './input-visuals' +import { createViewerXRStore } from './store' + +describe('createViewerXRStore', () => { + test('uses local controller and hand visuals that do not depend on remote model assets', () => { + const store = createViewerXRStore() + expect(store.getState().controller).toBe(VisibleXRController) + expect(store.getState().hand).toBe(VisibleXRHand) + store.destroy() + }) + + test('covers every standard WebXR hand joint', () => { + expect(HAND_JOINTS).toHaveLength(25) + expect(new Set(HAND_JOINTS).size).toBe(HAND_JOINTS.length) + expect(HAND_JOINTS).toContain('wrist') + expect(HAND_JOINTS).toContain('index-finger-tip') + expect(HAND_JOINTS).toContain('pinky-finger-tip') + }) +}) diff --git a/packages/viewer/src/xr/store.ts b/packages/viewer/src/xr/store.ts new file mode 100644 index 0000000000..d0c78112ff --- /dev/null +++ b/packages/viewer/src/xr/store.ts @@ -0,0 +1,17 @@ +import { + createXRStore, + type XRStore, + type XRStoreOptions, +} from '@react-three/xr' +import { VisibleXRController, VisibleXRHand } from './input-visuals' + +export type ViewerXRStore = XRStore + +export function createViewerXRStore(options: XRStoreOptions = {}): ViewerXRStore { + return createXRStore({ + controller: VisibleXRController, + hand: VisibleXRHand, + offerSession: false, + ...options, + }) +} diff --git a/packages/viewer/src/xr/support.ts b/packages/viewer/src/xr/support.ts new file mode 100644 index 0000000000..1c3de6170d --- /dev/null +++ b/packages/viewer/src/xr/support.ts @@ -0,0 +1,11 @@ +export type ImmersiveVRSupport = 'supported' | 'unsupported' + +export async function getImmersiveVRSupport(): Promise { + if (typeof navigator === 'undefined' || !navigator.xr) return 'unsupported' + + try { + return (await navigator.xr.isSessionSupported('immersive-vr')) ? 'supported' : 'unsupported' + } catch { + return 'unsupported' + } +} diff --git a/patches/iwer@2.3.0.patch b/patches/iwer@2.3.0.patch new file mode 100644 index 0000000000..9e6cfd1195 --- /dev/null +++ b/patches/iwer@2.3.0.patch @@ -0,0 +1,26 @@ +diff --git a/lib/device/XRTrackedInput.js b/lib/device/XRTrackedInput.js +index c767f8d90ce42049d6c5db5320ca78f6766ee162..1cf696736f6e7865d42e6b84c7be177c49d765d6 100644 +--- a/lib/device/XRTrackedInput.js ++++ b/lib/device/XRTrackedInput.js +@@ -67,10 +67,6 @@ export class XRTrackedInput { + if (button[P_GAMEPAD].eventTrigger != null) { + if (button[P_GAMEPAD].lastFrameValue === 0 && + button[P_GAMEPAD].value > 0) { +- session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, { +- frame, +- inputSource: this[P_TRACKED_INPUT].inputSource, +- })); + session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger + 'start', { + frame, + inputSource: this[P_TRACKED_INPUT].inputSource, +@@ -82,6 +78,10 @@ export class XRTrackedInput { + frame, + inputSource: this[P_TRACKED_INPUT].inputSource, + })); ++ session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, { ++ frame, ++ inputSource: this[P_TRACKED_INPUT].inputSource, ++ })); + } + } + } diff --git a/patches/three@0.185.1.patch b/patches/three@0.185.1.patch new file mode 100644 index 0000000000..ac8bdf29ce --- /dev/null +++ b/patches/three@0.185.1.patch @@ -0,0 +1,39 @@ +diff --git a/build/three.webgpu.js b/build/three.webgpu.js +index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..4d46bd53869497fb43debdd56037ecb673c6d85c 100644 +--- a/build/three.webgpu.js ++++ b/build/three.webgpu.js +@@ -14495,1 +14495,5 @@ +- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { ++ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { ++ ++ if ( frame === undefined ) return; ++ ++ const { camera } = frame; +@@ -68378,1 +68382,1 @@ +- if ( renderContext.textures !== null ) { ++ if ( renderContext.textures !== null && framebuffer !== null ) { +diff --git a/build/three.webgpu.nodes.js b/build/three.webgpu.nodes.js +index f707acc24f5711fdae065ce920b08435b0869c1b..270c6824ce277ec0b14a56a34f1f487298e968d5 100644 +--- a/build/three.webgpu.nodes.js ++++ b/build/three.webgpu.nodes.js +@@ -68378,1 +68378,1 @@ +- if ( renderContext.textures !== null ) { ++ if ( renderContext.textures !== null && framebuffer !== null ) { +diff --git a/src/nodes/accessors/Camera.js b/src/nodes/accessors/Camera.js +index 5e89cb93c61f37a1a2d03a27735c74287c0f0e9f..167c7226183d62af9ec61911a742e2445f58a5bc 100644 +--- a/src/nodes/accessors/Camera.js ++++ b/src/nodes/accessors/Camera.js +@@ -318,1 +318,5 @@ +- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { ++ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { ++ ++ if ( frame === undefined ) return; ++ ++ const { camera } = frame; +diff --git a/src/renderers/webgl-fallback/utils/WebGLState.js b/src/renderers/webgl-fallback/utils/WebGLState.js +index adf1eae5944f91aef5964b4754b0c0f7fc9f5cdc..5ffca30b3e4a94be6ca4e3db68ecc03a051d6d3f 100644 +--- a/src/renderers/webgl-fallback/utils/WebGLState.js ++++ b/src/renderers/webgl-fallback/utils/WebGLState.js +@@ -1135,1 +1135,1 @@ +- if ( renderContext.textures !== null ) { ++ if ( renderContext.textures !== null && framebuffer !== null ) { diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index d4ed804517..f45357f4bf 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -18,6 +18,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [capture-runtime](capture-runtime.md) | Open capture protocol, host source boundary, static/live viewer layers, and stream extension | +| [xr](xr.md) | WebXR ownership, renderer switching, session lifecycle, local emulation, and folder structure | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [selection-groups](selection-groups.md) | Session multi-select groups (Ctrl/Cmd+G), expand-on-click, how they differ from collections | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | diff --git a/wiki/architecture/xr.md b/wiki/architecture/xr.md new file mode 100644 index 0000000000..29725da822 --- /dev/null +++ b/wiki/architecture/xr.md @@ -0,0 +1,117 @@ +# WebXR + +WebXR is an optional presentation path for the existing scene. It does not add XR data to the scene graph and therefore has no code in `packages/core`. + +## Folder structure + +```text +packages/viewer/src/xr/ +├── god-mode/ # Encapsulates God-scale scene transforms, controller grips, palm grabs, and reset state. +├── human-mode/ # Owns first-person movement, snap turn, hand locomotion, comfort, and scene collision. +├── mode-switching/ # Coordinates God/Human transitions without changing persisted scene data. +├── presentation-context.tsx # Tells renderers when the scene is using direct immersive presentation. +├── session-root.tsx # Connects the R3F scene to an XR store and hands frame timing to the headset. +├── store.ts # Creates the reusable XR session store with default hand/controller rendering. +└── support.ts # Performs the safe immersive-vr browser capability check. + +apps/editor/components/xr/ +├── wand-panel/ # Left-hand three-face Build, Paint, and selection-aware Settings UI. +├── xr-editor-input-bridge.tsx # Adapts controller trigger and hand pinch rays to the editor's existing pointer/event pipeline. +├── xr-emulator-test-harness.tsx # Exposes development-only, event-observable controller and hand scenarios. +├── xr-preview-environment.tsx # Dedicated scene loader and launch surface for XR testing. +└── xr-runtime.tsx # Owns XR runtime state, session requests, and the Viewer XR configuration. + +apps/editor/lib/xr/ +├── editor-input.ts # Pure controller/hand source selection and XR button edge detection. +├── emulator-ray.ts # Resolves deterministic controller/hand poses for emulator targets. +├── emulator.ts # Installs the Quest 3 IWER emulator only in local development when native XR is absent. +├── settings.ts # Resolves registry settings for selected nodes and pre-placement tool defaults. +├── wand-panel-settings.ts # Holds session-only wand presentation preferences such as panel scale. +└── preview-window.ts # Opens or focuses the standalone XR testing window. + +apps/editor/lib/build-palette.ts # Shared palette definitions and activators used by desktop and XR build surfaces. + +apps/editor/app/xr/ +├── page.tsx # Tests the local editor scene. +└── scene/[id]/page.tsx # Tests a persisted scene by id. +``` + +## Ownership + +- `packages/viewer` owns renderer and session integration because those are generic presentation concerns. Its public API is `createViewerXRStore()`, `getImmersiveVRSupport()`, and the optional `Viewer.xr` configuration. `Viewer.xr.inputSourceOverlay` is a presentation-only extension point rendered inside each controller/hand context. +- `packages/viewer/src/xr/god-mode` owns the reusable God-scale interaction module. It transforms a presentation-only scene root and never writes scene graph data. +- `packages/viewer/src/xr/human-mode` owns reusable first-person XR input and collision. It operates on the XR origin and rendered mesh BVHs, not editor tools or scene graph state. +- `packages/viewer/src/xr/mode-switching` owns the presentation-only transition between God and Human scale and restores the prior God transform when switching back. +- `packages/editor` only passes the host-provided XR configuration through to its main viewer canvas. +- `apps/editor` owns the dedicated XR routes, toolbar button, development emulator, tracked-input adapter, and spatial editor panels. The panels select tools through the shared build palette, materials through the core material library, and settings through registry parametrics; they do not duplicate placement or geometry rules. +- `packages/core` remains unchanged because entering XR does not change persisted scene data. + +## Renderer policy + +Desktop mode keeps the existing automatic renderer selection: WebGPU is preferred and WebGL2 is the fallback. + +XR mode runs only under `/xr` or `/xr/scene/[id]` and mounts the canvas with `WebGPURenderer({ forceWebGL: true, multiview: false })`. The editor keeps its existing desktop renderer and does not remount when XR begins. The XR renderer remains Three.js `WebGPURenderer`, but its backend is WebGL2. This gives WebXR a predictable WebGL context and isolates emulator state from the editing session. Three.js `0.185.1` is pinned at the workspace root. Multiview remains disabled for the initial compatibility baseline and can be enabled after validation on physical headsets. + +The icon-only VR button sits beside Walkthrough and Preview in the editor toolbar. Its click opens or focuses a named XR testing window. That window has its own explicit session-start button because native WebXR requires user activation in the same browsing context that requests the immersive session. + +Before opening the testing window, the toolbar snapshots the editor's current in-memory scene into a dedicated XR preview key and marks the route to consume that snapshot. This keeps the immersive scene aligned with unsaved or debounce-pending edits instead of depending on the last autosave or API response. Direct visits to a persisted scene XR URL still load that scene through the API. + +The TSL post-processing pipeline is unmounted while XR mode is configured. XR uses one dedicated direct-render driver after scene systems run, avoiding SSGI, denoise, ink, and outline passes that have not been validated for stereo XR rendering. The driver updates Three's stereo union camera before drawing and prevents a second automatic camera update during that draw. + +The desktop frame limiter pauses while an immersive session is active. React Three Fiber then renders from the WebXR animation loop at the headset's cadence and receives the current `XRFrame`. + +The standalone XR route mounts the editor's existing selection manager, grid, node handles, and `ToolManager` as children of ``. Desktop camera controls, labels, post-processing, and thumbnail capture remain unmounted. Ending or unmounting XR also ends its active session. + +Controller trigger and tracked-hand pinch use the pointer implementation supplied by `@react-three/xr`. The app-level XR input bridge independently intersects the controller/hand target ray with the existing editor grid and emits the same `grid:move`, `grid:pointerdown`, `grid:pointerup`, and `grid:click` events consumed on desktop. Holding trigger or pinch on an already-selected movable node enters the existing press-drag move path; release is forwarded to its existing commit-on-release listener. The right controller B button emits the existing `tool:cancel` event. No XR-specific scene mutation or placement algorithm exists. + +Wall-hosted tools receive the existing `wall:enter`, `wall:move`, and `wall:click` events. The wall collision mesh stays render-active with color and depth writes disabled; setting the mesh or its material invisible removes it from the spatial-pointer traversal even though a direct Three.js raycast can still report it. This keeps door and window placement on the same host-resolution path as desktop input. + +Editor selection and manipulation use the ray pointer exclusively; near-field grab and touch pointers do not compete for the same scene node. Ray filtering accepts both direct R3F handlers and handlers inherited from a rendered ancestor. This is required for imported GLB items, elevators, and other renderers whose event handlers live on a wrapper while the raycastable meshes are nested below it. + +The left controller grip or left middle-finger metacarpal carries the three-face wand panel copied from the WebXR Home attachment geometry. Its labels use canvas textures and its borders use standard Three.js lines because Drei's Troika text and fat-line shader materials are incompatible with the XR renderer's node-material path. Ring arrows rotate between Build, Paint, and Settings. Build is paginated and exposes nested Roof and MEP pages using the same icons and activation functions as the desktop Build tab. Paint is always present and pages through the live core material library. Settings follows the single selected node and derives number, boolean, enum, vector, and read-only fallback rows from `nodeRegistry`; writes use the same derive/reconcile commit helper as the desktop parametric inspector. The input bridge suppresses grid authoring while a target ray intersects the wand, so pressing a panel control cannot also place scene geometry. + +Every spatial control has a stable `xr-*` object name. Select is pinned as the first Build tile on every main, Roof, and MEP page so every tool has an immediate spatial escape path. The Settings face uses one flattened sequence for visible registry fields, vector axes, actions, and live tool-hint chips, so pagination cannot hide a second independent control list. A selected node has priority; otherwise the active build tool is shown with registry defaults merged with editor tool defaults, and edits are saved before placement. Tool-hint chips use the same store and cycle action as desktop helpers and keyboard shortcuts, including cabinet-versus-island placement. Unsupported custom DOM editors are labelled as desktop-only instead of pretending to be editable in XR. + +When no item or build tool owns Settings, the face exposes Undo and Redo through the shared editor history controller, plus the presentation-only God-view reset and wand scale. The Wall Snap control edits the same per-context Grid, Lines, Angles, and Off state consumed by desktop wall drafting. This preserves standalone Zundo and host-provided collaborative history behavior, disables history or view jumps while an interaction scope is active, and keeps panel sizing out of persisted scene data. + +The Paint face uses the shared material library and material-paint state. Its scope control remembers the last paintable surface while the ray moves from the scene to the wrist panel, because leaving the scene clears the live hover before the spatial button is pressed. The chosen scope is still committed through the shared registry paint capability when the ray returns to the surface. + +Terrain mode keeps the desktop terrain model and undo boundary: an XR select press freezes the field snapshot, controller movement or hand motion advances the same saturating brush stroke, and release commits one scene-history step. The Settings face becomes the terrain control surface while the mode is active, exposing verb, brush dimensions, flatten sampling, lot leveling, and reset without introducing a second terrain state. + +MEP tools consume those live tool defaults when previewing and committing. Duct terminals use grid events for floor placement, wall events for wall placement, and spatial node rays for ceiling placement, so controller triggers and hand pinches follow the same placement contracts as desktop input. + +## Local testing + +Run: + +```bash +bun dev:xr +``` + +This starts the Next.js editor on all interfaces with its development HTTPS certificate. Open a scene and click the VR headset icon beside Walkthrough and Preview. Clicking the active icon exits VR. + +- On a desktop browser without native immersive WebXR, the app dynamically imports IWER and emulates a Meta Quest 3. The emulator is registered once across development hot reloads. +- The IWER DevUI is registered with the emulated device, so entering VR shows headset and controller transforms, buttons, sticks, reset, play mode, and session-exit controls over the XR canvas. +- The standalone test environment explicitly mounts the DevUI canvas and controls while an emulated session is active. This covers Three's forced-WebGL backend, which can initialize the XR session without invoking IWER's normal base-layer attachment callback. +- Controllers and hands use the same `DefaultXRController` and `DefaultXRHand` implementations as WebXR Home, with the editor-owned wand injected through the generic viewer input overlay. +- The XR camera uses the reference project's `0.001–10000` clipping range and an explicit `XROrigin`. The standalone preview starts in God mode at the reference project's elevated `[0, 4.5, 8]` origin. +- God mode wraps only rendered scene geometry in `xr-player-scene-root`; lights, cameras, controller/hand models, and the XR origin remain outside that transform. One grip pans the scene, two grips pan/rotate/scale it, and a held three-finger curl exposes the same grab interaction for tracked hands. +- Reset restores the scene root to identity and the XR origin to the default God-view pose. These are presentation transforms and are never persisted to `packages/core`. +- Human mode restores the scene to world scale. The left controller stick moves relative to head direction, the right stick snap-turns, and movement is resolved through a player capsule against the rendered scene's BVHs. +- With hand tracking, pinching inside the left wrist zone drives locomotion and pinching inside the right wrist zone drives turning. Movement and turns use the same comfort vignette and haptic feedback behavior as WebXR Home. +- Press the left controller Y button or use the mode button in the test environment to switch between God and Human mode. Standalone viewer integrations can also hold both tracked thumb tips together for 0.8 seconds. The editor disables that proximity gesture because it conflicts with precise hand interaction on the wand; use **Settings → XR scale** instead. Returning to God mode restores the scene transform captured before entering Human mode. +- XR supplies the theme's neutral base background because the desktop sky gradient belongs to the post-processing pipeline. The zenith colour is not flattened across the immersive view. +- The site's presentation-only horizon disc is suppressed in immersive XR because its fade depends on the desktop post-processing backdrop. The real site ground, slabs, terrain, and scene geometry remain visible. +- The Synthetic Environment Module is not registered for VR testing because it adds its own floor grid and environment canvas. Add it only when an AR/MR feature needs synthetic planes, meshes, depth, or hit testing. +- On a browser or headset with native immersive WebXR, the emulator is not installed. +- Production builds never load or install IWER. +- Development sessions expose `__pascalXRTestHarness`. It aims the emulated right controller or hand at stable spatial-control names and drives the actual IWER trigger/pinch transition. A click succeeds only after the target receives its R3F click event; snapshots report hover, delivered pointer and grid events, editor mode/tool/scope, selection, and scene counts. `clickLevelPoint` drives the existing grid event pipeline at a level-local plan coordinate, while `placeToolOnGrid` verifies tool activation, delivered points, newly created node IDs, and cancellation back to Select. `placeToolOnNode` additionally verifies delivery to a host surface and checks that the committed child references that host. This makes panel, selection, placement, and manipulation checks observable rather than timing-only smoke tests. +- A physical headset must trust the development certificate when connecting over the local network. `localhost` testing can use the normal development command, but HTTPS is the reliable path for another device. + +The neighboring `WebXR Home` project uses `@iwsdk/vite-plugin-dev`. That plugin is intentionally not copied because this app runs on Next.js rather than Vite. Direct IWER initialization provides the equivalent local emulator without adding a second app runtime or IWSDK scene engine. + +The toolbar remains icon-only. Hovering the headset icon reports `Enter VR with IWER emulator` when the emulated runtime is active. After entry, use the DevUI panels to connect or move controllers and the top controls to move or reset the headset. No Chrome extension is required for this development path. + +## Current scope + +The XR preview renders the existing scene with default controller and hand models, God-scale navigation, Human-mode locomotion, the existing 3D authoring tools, and the left-hand three-face editor wand. Scene graphs are normalized through each registered node schema before they reach renderers, so older snapshots receive required defaults such as site polygons and building transforms. The active desktop phase/mode/tool preference is rehydrated in the standalone XR window. Trigger or hand pinch can draw and place through the existing grid and node event pipeline; selecting a movable node and holding the trigger/pinch routes through its existing mover. Custom DOM-only inspector editors remain desktop-only and appear as read-only fallback rows in the spatial Settings face. XR-specific scene mutations remain out of scope. From 922f5cd70452ea38a419deda0820a44ba44ce562 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 9 Sep 2026 11:06:04 +0530 Subject: [PATCH 05/19] chore: remove WebXR integration --- XR-CHECKLIST.md | 28 - apps/editor/app/page.tsx | 18 +- apps/editor/app/xr/page.tsx | 10 - apps/editor/app/xr/scene/[id]/page.tsx | 13 - apps/editor/components/scene-loader.tsx | 21 +- .../components/xr/wand-panel/build-panel.tsx | 251 ------ apps/editor/components/xr/wand-panel/index.ts | 1 - .../components/xr/wand-panel/paint-panel.tsx | 355 -------- .../components/xr/wand-panel/panel-icon.tsx | 81 -- .../components/xr/wand-panel/panel-layout.ts | 52 -- .../xr/wand-panel/settings-panel.tsx | 754 ----------------- .../xr/wand-panel/spatial-controls.tsx | 476 ----------- .../components/xr/wand-panel/spatial-line.tsx | 53 -- .../components/xr/wand-panel/spatial-text.tsx | 124 --- .../xr/wand-panel/terrain-settings-panel.tsx | 146 ---- apps/editor/components/xr/wand-panel/theme.ts | 9 - .../components/xr/wand-panel/wand-panel.tsx | 49 -- .../xr/wand-panel/xr-wand-input-overlay.tsx | 36 - .../components/xr/xr-editor-input-bridge.tsx | 721 ---------------- .../xr/xr-emulator-test-harness.tsx | 779 ------------------ .../components/xr/xr-preview-environment.tsx | 329 -------- .../xr/xr-render-error-boundary.tsx | 48 -- apps/editor/components/xr/xr-runtime.tsx | 79 -- apps/editor/lib/bootstrap.ts | 4 - apps/editor/lib/xr/editor-input.test.ts | 146 ---- apps/editor/lib/xr/editor-input.ts | 126 --- apps/editor/lib/xr/emulator-ray.test.ts | 23 - apps/editor/lib/xr/emulator-ray.ts | 29 - apps/editor/lib/xr/emulator.ts | 74 -- apps/editor/lib/xr/preview-window.test.ts | 30 - apps/editor/lib/xr/preview-window.ts | 32 - .../editor/lib/xr/reference-space-ray.test.ts | 37 - apps/editor/lib/xr/reference-space-ray.ts | 22 - apps/editor/lib/xr/settings.test.ts | 106 --- apps/editor/lib/xr/settings.ts | 182 ---- .../editor/lib/xr/wand-panel-settings.test.ts | 74 -- apps/editor/lib/xr/wand-panel-settings.ts | 58 -- apps/editor/lib/xr/wand-panel.test.ts | 99 --- apps/editor/next.config.ts | 1 - apps/editor/package.json | 4 - apps/editor/tsconfig.json | 1 - package.json | 2 - packages/viewer/package.json | 1 - packages/viewer/src/index.ts | 10 - .../src/xr/distance-aware-ray-pointer.tsx | 162 ---- packages/viewer/src/xr/frame-loop.test.ts | 192 ----- packages/viewer/src/xr/frame-loop.ts | 135 --- .../god-mode/constants/god-mode-constants.ts | 4 - packages/viewer/src/xr/god-mode/index.ts | 3 - .../god-mode/input/god-mode-hand-controls.tsx | 95 --- .../src/xr/god-mode/lib/palm-grab.test.ts | 36 - .../viewer/src/xr/god-mode/lib/palm-grab.ts | 74 -- .../xr/god-mode/lib/scale-interaction.test.ts | 109 --- .../src/xr/god-mode/lib/scale-interaction.ts | 155 ---- .../store/god-mode-hand-store.test.ts | 20 - .../xr/god-mode/store/god-mode-hand-store.ts | 48 -- .../store/god-mode-view-store.test.ts | 15 - .../xr/god-mode/store/god-mode-view-store.ts | 15 - .../src/xr/god-mode/ui/god-mode-controls.tsx | 172 ---- .../constants/human-mode-constants.ts | 8 - packages/viewer/src/xr/human-mode/index.ts | 5 - .../input/controller-locomotion.tsx | 89 -- .../xr/human-mode/input/hand-locomotion.tsx | 210 ----- .../human-mode/input/human-collision-rig.tsx | 87 -- .../human-mode/lib/capsule-collision.test.ts | 45 - .../xr/human-mode/lib/capsule-collision.ts | 119 --- .../viewer/src/xr/human-mode/lib/comfort.ts | 7 - .../src/xr/human-mode/lib/hand-locomotion.ts | 79 -- .../viewer/src/xr/human-mode/lib/hand-pose.ts | 38 - .../viewer/src/xr/human-mode/lib/haptics.ts | 10 - .../src/xr/human-mode/lib/human-input.test.ts | 61 -- .../src/xr/human-mode/lib/locomotion.test.ts | 36 - .../src/xr/human-mode/lib/locomotion.ts | 43 - .../xr/human-mode/lib/origin-navigation.ts | 30 - .../viewer/src/xr/human-mode/lib/snap-turn.ts | 14 - .../xr/human-mode/store/collision-store.ts | 16 - .../store/hand-locomotion-joystick.ts | 60 -- .../human-mode/store/locomotion-settings.ts | 16 - .../src/xr/human-mode/ui/comfort-vignette.tsx | 53 -- .../xr/human-mode/ui/hand-locomotion-zone.tsx | 178 ---- .../xr/human-mode/ui/human-mode-controls.tsx | 19 - packages/viewer/src/xr/input-visuals.tsx | 174 ---- .../viewer/src/xr/mode-switching/index.ts | 7 - .../lib/scene-scale-transition.test.ts | 34 - .../lib/scene-scale-transition.ts | 72 -- .../lib/thumb-mode-gesture.test.ts | 29 - .../mode-switching/lib/thumb-mode-gesture.ts | 42 - .../mode-switching/store/player-mode.test.ts | 15 - .../xr/mode-switching/store/player-mode.ts | 27 - .../mode-switching/ui/player-mode-scene.tsx | 300 ------- packages/viewer/src/xr/pointer-cursor.test.ts | 36 - packages/viewer/src/xr/pointer-cursor.ts | 24 - packages/viewer/src/xr/pointer-filter.test.ts | 57 -- packages/viewer/src/xr/pointer-filter.ts | 19 - .../viewer/src/xr/pointer-ring-material.ts | 12 - .../src/xr/presentation-background.test.ts | 10 - .../viewer/src/xr/presentation-background.ts | 5 - .../viewer/src/xr/presentation-context.tsx | 23 - packages/viewer/src/xr/session-root.tsx | 168 ---- packages/viewer/src/xr/store.test.ts | 22 - packages/viewer/src/xr/store.ts | 17 - packages/viewer/src/xr/support.ts | 11 - patches/iwer@2.3.0.patch | 26 - wiki/architecture/README.md | 1 - wiki/architecture/xr.md | 117 --- 105 files changed, 7 insertions(+), 9193 deletions(-) delete mode 100644 XR-CHECKLIST.md delete mode 100644 apps/editor/app/xr/page.tsx delete mode 100644 apps/editor/app/xr/scene/[id]/page.tsx delete mode 100644 apps/editor/components/xr/wand-panel/build-panel.tsx delete mode 100644 apps/editor/components/xr/wand-panel/index.ts delete mode 100644 apps/editor/components/xr/wand-panel/paint-panel.tsx delete mode 100644 apps/editor/components/xr/wand-panel/panel-icon.tsx delete mode 100644 apps/editor/components/xr/wand-panel/panel-layout.ts delete mode 100644 apps/editor/components/xr/wand-panel/settings-panel.tsx delete mode 100644 apps/editor/components/xr/wand-panel/spatial-controls.tsx delete mode 100644 apps/editor/components/xr/wand-panel/spatial-line.tsx delete mode 100644 apps/editor/components/xr/wand-panel/spatial-text.tsx delete mode 100644 apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx delete mode 100644 apps/editor/components/xr/wand-panel/theme.ts delete mode 100644 apps/editor/components/xr/wand-panel/wand-panel.tsx delete mode 100644 apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx delete mode 100644 apps/editor/components/xr/xr-editor-input-bridge.tsx delete mode 100644 apps/editor/components/xr/xr-emulator-test-harness.tsx delete mode 100644 apps/editor/components/xr/xr-preview-environment.tsx delete mode 100644 apps/editor/components/xr/xr-render-error-boundary.tsx delete mode 100644 apps/editor/components/xr/xr-runtime.tsx delete mode 100644 apps/editor/lib/xr/editor-input.test.ts delete mode 100644 apps/editor/lib/xr/editor-input.ts delete mode 100644 apps/editor/lib/xr/emulator-ray.test.ts delete mode 100644 apps/editor/lib/xr/emulator-ray.ts delete mode 100644 apps/editor/lib/xr/emulator.ts delete mode 100644 apps/editor/lib/xr/preview-window.test.ts delete mode 100644 apps/editor/lib/xr/preview-window.ts delete mode 100644 apps/editor/lib/xr/reference-space-ray.test.ts delete mode 100644 apps/editor/lib/xr/reference-space-ray.ts delete mode 100644 apps/editor/lib/xr/settings.test.ts delete mode 100644 apps/editor/lib/xr/settings.ts delete mode 100644 apps/editor/lib/xr/wand-panel-settings.test.ts delete mode 100644 apps/editor/lib/xr/wand-panel-settings.ts delete mode 100644 apps/editor/lib/xr/wand-panel.test.ts delete mode 100644 packages/viewer/src/xr/distance-aware-ray-pointer.tsx delete mode 100644 packages/viewer/src/xr/frame-loop.test.ts delete mode 100644 packages/viewer/src/xr/frame-loop.ts delete mode 100644 packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts delete mode 100644 packages/viewer/src/xr/god-mode/index.ts delete mode 100644 packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx delete mode 100644 packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts delete mode 100644 packages/viewer/src/xr/god-mode/lib/palm-grab.ts delete mode 100644 packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts delete mode 100644 packages/viewer/src/xr/god-mode/lib/scale-interaction.ts delete mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts delete mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts delete mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts delete mode 100644 packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts delete mode 100644 packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx delete mode 100644 packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts delete mode 100644 packages/viewer/src/xr/human-mode/index.ts delete mode 100644 packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx delete mode 100644 packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx delete mode 100644 packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx delete mode 100644 packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/capsule-collision.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/comfort.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/hand-pose.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/haptics.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/human-input.test.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/locomotion.test.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/locomotion.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/origin-navigation.ts delete mode 100644 packages/viewer/src/xr/human-mode/lib/snap-turn.ts delete mode 100644 packages/viewer/src/xr/human-mode/store/collision-store.ts delete mode 100644 packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts delete mode 100644 packages/viewer/src/xr/human-mode/store/locomotion-settings.ts delete mode 100644 packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx delete mode 100644 packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx delete mode 100644 packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx delete mode 100644 packages/viewer/src/xr/input-visuals.tsx delete mode 100644 packages/viewer/src/xr/mode-switching/index.ts delete mode 100644 packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts delete mode 100644 packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts delete mode 100644 packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts delete mode 100644 packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts delete mode 100644 packages/viewer/src/xr/mode-switching/store/player-mode.test.ts delete mode 100644 packages/viewer/src/xr/mode-switching/store/player-mode.ts delete mode 100644 packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx delete mode 100644 packages/viewer/src/xr/pointer-cursor.test.ts delete mode 100644 packages/viewer/src/xr/pointer-cursor.ts delete mode 100644 packages/viewer/src/xr/pointer-filter.test.ts delete mode 100644 packages/viewer/src/xr/pointer-filter.ts delete mode 100644 packages/viewer/src/xr/pointer-ring-material.ts delete mode 100644 packages/viewer/src/xr/presentation-background.test.ts delete mode 100644 packages/viewer/src/xr/presentation-background.ts delete mode 100644 packages/viewer/src/xr/presentation-context.tsx delete mode 100644 packages/viewer/src/xr/session-root.tsx delete mode 100644 packages/viewer/src/xr/store.test.ts delete mode 100644 packages/viewer/src/xr/store.ts delete mode 100644 packages/viewer/src/xr/support.ts delete mode 100644 patches/iwer@2.3.0.patch delete mode 100644 wiki/architecture/xr.md diff --git a/XR-CHECKLIST.md b/XR-CHECKLIST.md deleted file mode 100644 index febd3a7d24..0000000000 --- a/XR-CHECKLIST.md +++ /dev/null @@ -1,28 +0,0 @@ -# XR Implementation Checklist - -- [x] 1. Create an authoritative XR tool manifest -- [x] 2. Build a deterministic emulator harness -- [x] 3. Verify controller pointer capture and drag lifecycle -- [x] 4. Verify hand pinch capture and drag lifecycle -- [x] 5. Verify panel selection and nested pagination -- [x] 6. Verify Select-tool fallback and cancellation -- [x] 7. Verify scene selection and deselection -- [x] 8. Verify movement and resize handles -- [x] 9. Verify settings-panel generation and updates -- [x] 10. Verify wall creation and editing -- [x] 11. Verify door and window wall placement -- [x] 12. Verify fence creation and editing -- [x] 13. Verify slab creation and editing -- [x] 14. Verify ceiling creation and editing -- [x] 15. Verify column and block workflows -- [x] 16. Verify elevator and spawn workflows -- [x] 17. Verify shelf, kitchen, and stair workflows -- [x] 18. Verify roof creation and editing -- [x] 19. Verify roof-feature placement and editing -- [x] 20. Verify MEP tool workflows -- [x] 21. Verify Paint tool workflows -- [x] 22. Verify terrain sculpting workflows -- [x] 23. Verify undo, redo, and interaction cancellation -- [x] 24. Verify God and Human mode workflows -- [x] 25. Verify controller/hand parity and XR rendering stability -- [ ] 26. Run complete emulator and physical-headset regression testing diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index c38d63071b..9361f3696f 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,8 +1,6 @@ 'use client' import { Editor, ItemsPanel } from '@pascal-app/editor' -import { createViewerXRStore } from '@pascal-app/viewer' -import { useWebXRFeature, WebXRToolbarButton } from '@pascal-local/plugin-webxr' import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' @@ -89,14 +87,9 @@ const SIDEBAR_TABS = [ const PROJECT_ID = 'local-editor' export default function Home() { - const webXR = useWebXRFeature(createViewerXRStore) - return ( -
- {PROJECT_ID === 'local-editor' && webXR.status !== 'active' && ( +
+ {PROJECT_ID === 'local-editor' && (
@@ -112,16 +105,11 @@ export default function Home() {
)} } - viewerToolbarRight={ - } /> - } - xr={webXR.xr} + viewerToolbarRight={} />
) diff --git a/apps/editor/app/xr/page.tsx b/apps/editor/app/xr/page.tsx deleted file mode 100644 index 09485a0a0b..0000000000 --- a/apps/editor/app/xr/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { XRPreviewEnvironment } from '@/components/xr/xr-preview-environment' - -export default async function LocalXRPreviewPage({ - searchParams, -}: { - searchParams: Promise<{ source?: string }> -}) { - const { source } = await searchParams - return -} diff --git a/apps/editor/app/xr/scene/[id]/page.tsx b/apps/editor/app/xr/scene/[id]/page.tsx deleted file mode 100644 index 872862222e..0000000000 --- a/apps/editor/app/xr/scene/[id]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { XRPreviewEnvironment } from '@/components/xr/xr-preview-environment' - -export default async function SceneXRPreviewPage({ - params, - searchParams, -}: { - params: Promise<{ id: string }> - searchParams: Promise<{ source?: string }> -}) { - const { id } = await params - const { source } = await searchParams - return -} diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 3267d34a96..a9df71ac96 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -9,8 +9,6 @@ import { type SceneGraph, type SidebarTab, } from '@pascal-app/editor' -import { createViewerXRStore } from '@pascal-app/viewer' -import { useWebXRFeature, WebXRToolbarButton } from '@pascal-local/plugin-webxr' import { Hammer, Layers, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' @@ -125,7 +123,6 @@ function sceneUrl( } export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { - const webXR = useWebXRFeature(createViewerXRStore) const router = useRouter() const searchParams = useSearchParams() const versionRef = useRef(meta.version) @@ -258,12 +255,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { ) return ( -
- {webXR.status !== 'active' && ( - <> +
+ <> {conflict && (

Another session saved first — refresh?

@@ -319,12 +312,9 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { All scenes
- - )} + } - viewerToolbarRight={ - } /> - } - xr={webXR.xr} + viewerToolbarRight={} />
) diff --git a/apps/editor/components/xr/wand-panel/build-panel.tsx b/apps/editor/components/xr/wand-panel/build-panel.tsx deleted file mode 100644 index 4177eb22fa..0000000000 --- a/apps/editor/components/xr/wand-panel/build-panel.tsx +++ /dev/null @@ -1,251 +0,0 @@ -'use client' - -import { type RoofType, RoofType as RoofTypeSchema, useRegistryVersion } from '@pascal-app/core' -import { useEditor, useFloorplanMode } from '@pascal-app/editor' -import { useMemo } from 'react' -import { - activateBuildTool, - activateModularCabinetTool, - activatePaintMode, - activateRoofFeatureTool, - activateRoofType, - activateSelectMode, - activateTerrainSculptMode, - collectBuildTypes, - collectRoofFeatures, - type RoofFeature, - XR_MEP_ITEMS, -} from '@/lib/build-palette' -import { ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' -import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' -import { PanelIcon } from './panel-icon' -import { getPageWithPinnedFirst } from './panel-layout' -import { PanelHeader, SpatialButton } from './spatial-controls' -import { SpatialText } from './spatial-text' -import { XR_WAND_THEME } from './theme' - -const ITEMS_PER_PAGE = 9 - -type PaletteEntry = { - active: boolean - iconSrc: string - id: string - label: string - select: () => void -} - -function tilePosition(index: number): [number, number, number] { - return [-0.255 + (index % 3) * 0.255, 0.225 - Math.floor(index / 3) * 0.215, 0] -} - -function PaletteTile({ entry, index }: { entry: PaletteEntry; index: number }) { - return ( - - - 12 ? 0.018 : 0.021} - maxWidth={0.19} - position={[0, -0.067, 0.012]} - textAlign="center" - > - {entry.label} - - - ) -} - -export function XRBuildPanel() { - const section = useXRWandPanelSettings((state) => state.buildSection) - const page = useXRWandPanelSettings((state) => state.buildPage) - const setBuildNavigation = useXRWandPanelSettings((state) => state.setBuildNavigation) - const mode = useEditor((state) => state.mode) - const activeTool = useEditor((state) => state.tool) - const roofDefaults = useEditor((state) => state.toolDefaults.roof) - const floorplanMode = useFloorplanMode((state) => state.mode) - const registryVersion = useRegistryVersion() - const buildTypes = useMemo(() => { - void registryVersion - return collectBuildTypes(floorplanMode) - }, [floorplanMode, registryVersion]) - const roofFeatures = useMemo(() => { - void registryVersion - return collectRoofFeatures() - }, [registryVersion]) - const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) - const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable' - - const entries = useMemo(() => { - const selectEntry: PaletteEntry = { - active: mode === 'select', - iconSrc: '/icons/select.webp', - id: 'select', - label: 'Select', - select: activateSelectMode, - } - - if (section === 'mep') { - return [ - selectEntry, - ...XR_MEP_ITEMS.map((item) => ({ - active: mode === 'build' && activeTool === item.kind, - iconSrc: item.iconSrc, - id: item.id, - label: item.label, - select: () => activateBuildTool(item.kind), - })), - ] - } - - if (section === 'roof') { - const roofTypes: PaletteEntry[] = ROOF_TYPE_OPTIONS.map((option) => ({ - active: mode === 'build' && activeTool === 'roof' && activeRoofType === option.value, - iconSrc: '/icons/roof.webp', - id: `roof-${option.value}`, - label: option.label, - select: () => activateRoofType(option.value as RoofType), - })) - return [ - selectEntry, - ...roofTypes, - ...roofFeatures.map((feature: RoofFeature) => ({ - active: mode === 'build' && activeTool === feature.kind, - iconSrc: feature.iconSrc, - id: feature.id, - label: feature.label, - select: () => activateRoofFeatureTool(feature), - })), - ] - } - - return [ - selectEntry, - ...buildTypes.map((type) => { - const isMepTool = - !!activeTool && - (activeTool.includes('duct') || - activeTool.includes('pipe') || - activeTool === 'lineset' || - activeTool === 'liquid-line' || - activeTool === 'hvac-equipment') - const active = type.mode - ? mode === type.mode - : type.id === 'kitchen' - ? mode === 'build' && activeTool === 'cabinet' - : type.id === 'mep' - ? mode === 'build' && isMepTool - : mode === 'build' && activeTool === type.kind - return { - active, - iconSrc: type.iconSrc, - id: type.id, - label: type.label, - select: () => { - if (type.id === 'mep') { - activateBuildTool('duct-segment') - setBuildNavigation('mep', 0) - } else if (type.id === 'roof') { - activateBuildTool('roof') - setBuildNavigation('roof', 0) - } else if (type.id === 'kitchen') { - activateModularCabinetTool() - } else if (type.mode === 'material-paint') { - activatePaintMode() - } else if (type.mode === 'terrain-sculpt') { - activateTerrainSculptMode() - } else if (type.kind) { - activateBuildTool(type.kind) - } - }, - } - }), - ] - }, [activeRoofType, activeTool, buildTypes, mode, roofFeatures, section, setBuildNavigation]) - - const current = getPageWithPinnedFirst(entries, page, ITEMS_PER_PAGE) - const title = section === 'main' ? 'Build' : section === 'mep' ? 'MEP' : 'Roof' - - return ( - - - {section !== 'main' && ( - { - setBuildNavigation('main', 0) - }} - position={[-0.3, 0.35, 0]} - size={[0.12, 0.055]} - > - - Back - - - )} - {current.items.map((entry, index) => ( - - ))} - {current.pageCount > 1 && ( - - setBuildNavigation(section, current.currentPage - 1)} - position={[-0.07, 0, 0]} - size={[0.055, 0.055]} - > - - ‹ - - - - {current.currentPage + 1}/{current.pageCount} - - = current.pageCount - 1} - name={`xr-build-${section}-next-page`} - onClick={() => setBuildNavigation(section, current.currentPage + 1)} - position={[0.07, 0, 0]} - size={[0.055, 0.055]} - > - - › - - - - )} - - ) -} diff --git a/apps/editor/components/xr/wand-panel/index.ts b/apps/editor/components/xr/wand-panel/index.ts deleted file mode 100644 index 25c7f85fc3..0000000000 --- a/apps/editor/components/xr/wand-panel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { XRWandInputOverlay } from './xr-wand-input-overlay' diff --git a/apps/editor/components/xr/wand-panel/paint-panel.tsx b/apps/editor/components/xr/wand-panel/paint-panel.tsx deleted file mode 100644 index 42f3043565..0000000000 --- a/apps/editor/components/xr/wand-panel/paint-panel.tsx +++ /dev/null @@ -1,355 +0,0 @@ -'use client' - -import { - getLibraryMaterialIdFromRef, - getLibraryMaterialsVersion, - getMaterialsForCategory, - MATERIAL_CATEGORIES, - type MaterialCatalogItem, - type MaterialCategory, - subscribeLibraryMaterials, - toLibraryMaterialRef, -} from '@pascal-app/core' -import { - cyclePaintScope, - getActivePaintMaterialLabel, - hasActivePaintMaterial, - type PaintHoverInfo, - paintScopeLabel, - useEditor, -} from '@pascal-app/editor' -import { useMemo, useRef, useSyncExternalStore } from 'react' -import { activatePaintMode } from '@/lib/build-palette' -import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' -import { PanelIcon } from './panel-icon' -import { getPage } from './panel-layout' -import { PanelHeader, SpatialButton } from './spatial-controls' -import { SpatialLine } from './spatial-line' -import { SpatialText } from './spatial-text' -import { XR_WAND_THEME } from './theme' - -const MATERIALS_PER_PAGE = 6 -const MATERIAL_TILE_SIZE: [number, number] = [0.215, 0.205] -const MATERIAL_PREVIEW_SIZE = 0.116 -const MATERIAL_GRID_TOP = 0.135 -const MATERIAL_GRID_ROW_GAP = 0.25 -const MATERIAL_PREVIEW_FRAME = MATERIAL_PREVIEW_SIZE / 2 - -const MATERIAL_PREVIEW_FRAME_POINTS: [number, number, number][] = [ - [-MATERIAL_PREVIEW_FRAME, -MATERIAL_PREVIEW_FRAME + 0.022, 0.014], - [MATERIAL_PREVIEW_FRAME, -MATERIAL_PREVIEW_FRAME + 0.022, 0.014], - [MATERIAL_PREVIEW_FRAME, MATERIAL_PREVIEW_FRAME + 0.022, 0.014], - [-MATERIAL_PREVIEW_FRAME, MATERIAL_PREVIEW_FRAME + 0.022, 0.014], - [-MATERIAL_PREVIEW_FRAME, -MATERIAL_PREVIEW_FRAME + 0.022, 0.014], -] - -function labelCategory(category: string) { - return `${category.charAt(0).toUpperCase()}${category.slice(1)}` -} - -function materialPosition(index: number): [number, number, number] { - return [ - -0.255 + (index % 3) * 0.255, - MATERIAL_GRID_TOP - Math.floor(index / 3) * MATERIAL_GRID_ROW_GAP, - 0, - ] -} - -function materialLabelFontSize(label: string) { - if (label.length > 16) return 0.016 - if (label.length > 11) return 0.017 - return 0.018 -} - -function MaterialTile({ - item, - index, - selected, - select, -}: { - item: MaterialCatalogItem - index: number - selected: boolean - select: () => void -}) { - return ( - - - - - {item.label} - - - ) -} - -export function XRPaintPanel() { - const categoryIndex = useXRWandPanelSettings((state) => state.paintCategoryIndex) - const page = useXRWandPanelSettings((state) => state.paintPage) - const setPaintNavigation = useXRWandPanelSettings((state) => state.setPaintNavigation) - const mode = useEditor((state) => state.mode) - const activePaintMaterial = useEditor((state) => state.activePaintMaterial) - const activePaintTarget = useEditor((state) => state.activePaintTarget) - const paintEraser = useEditor((state) => state.paintEraser) - const paintHover = useEditor((state) => state.paintHover) - const paintScope = useEditor((state) => state.paintScope) - const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) - const setPaintEraser = useEditor((state) => state.setPaintEraser) - const setPaintScope = useEditor((state) => state.setPaintScope) - const lastPaintHover = useRef(null) - if (mode !== 'material-paint') lastPaintHover.current = null - else if (paintHover) lastPaintHover.current = paintHover - const libraryVersion = useSyncExternalStore( - subscribeLibraryMaterials, - getLibraryMaterialsVersion, - getLibraryMaterialsVersion, - ) - - const availableCategories = useMemo(() => { - void libraryVersion - return MATERIAL_CATEGORIES.filter((category) => getMaterialsForCategory(category).length > 0) - }, [libraryVersion]) - const activeCategoryIndex = availableCategories.length - ? categoryIndex % availableCategories.length - : 0 - const category = (availableCategories[activeCategoryIndex] ?? - availableCategories[0] ?? - 'colors') as MaterialCategory - const materials = getMaterialsForCategory(category) - const current = getPage(materials, page, MATERIALS_PER_PAGE) - const selectedId = getLibraryMaterialIdFromRef(activePaintMaterial?.materialPreset) - const paintContext = paintHover ?? lastPaintHover.current - const paintEnabled = paintEraser || hasActivePaintMaterial(activePaintMaterial) - const availableScopes = paintContext?.scopes ?? ['single'] - const effectivePaintScope = availableScopes.includes(paintScope) ? paintScope : 'single' - const scopeLabel = !paintEnabled - ? 'Choose a material' - : paintContext - ? `Paint: ${paintScopeLabel(effectivePaintScope, paintContext)}` - : 'Aim at a surface' - - const changeCategory = (direction: -1 | 1) => { - if (availableCategories.length < 2) return - setPaintNavigation( - (activeCategoryIndex + direction + availableCategories.length) % availableCategories.length, - 0, - ) - } - - return ( - - - - changeCategory(-1)} - position={[-0.31, 0, 0]} - size={[0.075, 0.06]} - > - - ‹ - - - - {labelCategory(category)} · {activeCategoryIndex + 1}/{availableCategories.length} - - changeCategory(1)} - position={[0.31, 0, 0]} - size={[0.075, 0.06]} - > - - › - - - - - - - {mode === 'material-paint' ? 'Brush armed' : 'Start painting'} - - - { - activatePaintMode() - setPaintEraser(!paintEraser) - }} - position={[0.17, 0, 0]} - selected={paintEraser} - size={[0.3, 0.06]} - > - - Eraser - - - - {current.items.length > 0 ? ( - current.items.map((item, index) => ( - { - activatePaintMode() - setActivePaintMaterial({ - materialPreset: toLibraryMaterialRef(item.id), - sourceTarget: activePaintTarget, - }) - }} - selected={selectedId === item.id} - /> - )) - ) : ( - - No materials in this category - - )} - setPaintScope(cyclePaintScope(effectivePaintScope, availableScopes))} - position={[0, -0.265, 0]} - selected={availableScopes.length > 1 && effectivePaintScope !== 'single'} - size={[0.47, 0.055]} - > - - {scopeLabel} - - - - {current.pageCount > 1 && ( - setPaintNavigation(activeCategoryIndex, current.currentPage - 1)} - position={[-0.28, 0, 0]} - size={[0.075, 0.055]} - > - - ‹ - - - )} - - {current.items.length > 0 - ? getActivePaintMaterialLabel(activePaintMaterial) - : 'No materials'} - {current.pageCount > 1 ? ` · ${current.currentPage + 1}/${current.pageCount}` : ''} - - {current.pageCount > 1 && ( - = current.pageCount - 1} - name="xr-paint-next-page" - onClick={() => setPaintNavigation(activeCategoryIndex, current.currentPage + 1)} - position={[0.28, 0, 0]} - size={[0.075, 0.055]} - > - - › - - - )} - - - ) -} diff --git a/apps/editor/components/xr/wand-panel/panel-icon.tsx b/apps/editor/components/xr/wand-panel/panel-icon.tsx deleted file mode 100644 index 07d9bd23d9..0000000000 --- a/apps/editor/components/xr/wand-panel/panel-icon.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client' - -import { EDITOR_LAYER } from '@pascal-app/editor' -import { useTexture } from '@react-three/drei' -import { Component, type ReactNode, Suspense } from 'react' -import { SRGBColorSpace } from 'three' -import { XR_WAND_THEME } from './theme' - -function TextureIcon({ size, src }: { size: number; src: string }) { - const texture = useTexture(src) - texture.colorSpace = SRGBColorSpace - return ( - undefined} - > - - - - ) -} - -function ColorIcon({ color, size }: { color: string; size: number }) { - return ( - undefined} - > - - - - ) -} - -type TextureIconBoundaryProps = { - children: ReactNode - fallback: ReactNode -} - -type TextureIconBoundaryState = { - hasError: boolean -} - -class TextureIconBoundary extends Component { - state: TextureIconBoundaryState = { hasError: false } - - static getDerivedStateFromError(): TextureIconBoundaryState { - return { hasError: true } - } - - render() { - return this.state.hasError ? this.props.fallback : this.props.children - } -} - -export function PanelIcon({ - color = XR_WAND_THEME.border, - size = 0.09, - src, -}: { - color?: string - size?: number - src?: string -}) { - if (!src) { - return - } - - const fallback = - return ( - - - - - - ) -} diff --git a/apps/editor/components/xr/wand-panel/panel-layout.ts b/apps/editor/components/xr/wand-panel/panel-layout.ts deleted file mode 100644 index 1c927eac04..0000000000 --- a/apps/editor/components/xr/wand-panel/panel-layout.ts +++ /dev/null @@ -1,52 +0,0 @@ -export const XR_WAND_PANEL_LAYOUT = { - faceRadius: 0.076, - faceScale: 0.2925, - faceWidth: 0.82, - faceHeight: 1.04, - faceCornerRadius: 0.05, - faceAngles: [0, 120, 240] as const, - attachment: { - gripAxisOffset: -0.085, - gripScale: 0.66, - handSpace: 'middle-finger-metacarpal' as const, - handPosition: [0, -0.01, -0.05] as [number, number, number], - handRotation: [-0.2, 0, Math.PI] as [number, number, number], - handScale: 0.85, - }, -} as const - -export const XR_WAND_PANEL_INPUT_NAME = 'xr-editor-wand-panel' - -export function resolveWandPanelFacePose(index: number, handedness: XRHandedness = 'left') { - const angleDegrees = XR_WAND_PANEL_LAYOUT.faceAngles[index] ?? 0 - const angle = (angleDegrees * Math.PI) / 180 - const mirror = handedness === 'right' ? -1 : 1 - const radialX = Math.sin(angle) * mirror - return { - position: [ - radialX * XR_WAND_PANEL_LAYOUT.faceRadius, - Math.cos(angle) * XR_WAND_PANEL_LAYOUT.faceRadius, - 0, - ] as [number, number, number], - rotation: [-Math.PI / 2, Math.atan2(radialX, Math.cos(angle)), 0] as [number, number, number], - } -} - -export function getPage(items: readonly T[], page: number, pageSize: number) { - const pageCount = Math.max(1, Math.ceil(items.length / pageSize)) - const currentPage = Math.min(Math.max(0, page), pageCount - 1) - return { - currentPage, - pageCount, - items: items.slice(currentPage * pageSize, (currentPage + 1) * pageSize), - } -} - -export function getPageWithPinnedFirst(items: readonly T[], page: number, pageSize: number) { - const pinned = items[0] - const current = getPage(items.slice(1), page, Math.max(1, pageSize - 1)) - return { - ...current, - items: pinned === undefined ? current.items : [pinned, ...current.items], - } -} diff --git a/apps/editor/components/xr/wand-panel/settings-panel.tsx b/apps/editor/components/xr/wand-panel/settings-panel.tsx deleted file mode 100644 index 7330cfc8bb..0000000000 --- a/apps/editor/components/xr/wand-panel/settings-panel.tsx +++ /dev/null @@ -1,754 +0,0 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - type BuildingNode, - DEFAULT_LEVEL_HEIGHT, - getLevelDisplayName, - getLibraryMaterialIdFromRef, - getLibraryMaterialsVersion, - getMaterialsForCategory, - LevelNode, - MATERIAL_CATEGORIES, - type ParamAction, - type RoofNode, - RoofType as RoofTypeSchema, - subscribeLibraryMaterials, - toLibraryMaterialRef, - useRegistryVersion, - useScene, -} from '@pascal-app/core' -import { - commitParametricNodeFields, - cycleSnappingModeIn, - emitDeleteSFX, - getHistoryCommandState, - getSnappingModeLabel, - runRedo, - runUndo, - subscribeHistoryCommandState, - triggerSFX, - useEditor, - useInteractionScope, -} from '@pascal-app/editor' -import { - requestGodScaleReset, - toggleXRPlayerMode, - useViewer, - useXRPlayerMode, - XR_PLAYER_MODES, -} from '@pascal-app/viewer' -import { useMemo, useSyncExternalStore } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { - activateRoofFeatureTool, - activateRoofFootprintSource, - collectRoofFeatures, -} from '@/lib/build-palette' -import { getRoofFootprintSources } from '@/lib/build-tab-state' -import { - collectXRSettingRows, - createXRSettingPatch, - readXRSettingValue, - resolveXRSettingsContext, - type XRSettingActionRow, - type XRSettingFieldRow, - type XRSettingRow, - type XRSettingsContext, - type XRSettingToolChipRow, -} from '@/lib/xr/settings' -import { - useXRWandPanelSettings, - XR_WAND_PANEL_SCALE_MAX, - XR_WAND_PANEL_SCALE_MIN, - XR_WAND_PANEL_SCALE_STEP, -} from '@/lib/xr/wand-panel-settings' -import { getPage } from './panel-layout' -import { - PageArrows, - PanelHeader, - PanelHint, - SettingChoice, - SettingCycle, - SettingStepper, - SpatialButton, -} from './spatial-controls' -import { SpatialText } from './spatial-text' -import { XRTerrainSettingsPanel } from './terrain-settings-panel' -import { XR_WAND_THEME } from './theme' - -const ROWS_PER_PAGE = 5 -const DEFAULT_SETTINGS_CONTEXT_KEY = 'default-settings' -const DEFAULT_SETTINGS_PAGES = 2 -const XR_COLORS = ['#888888', '#ffffff', '#18181b', '#ef4444', '#22c55e', '#3b82f6'] - -function collectRoofActionRows(node: AnyNode): XRSettingActionRow[] { - if (node.type !== 'roof' && node.type !== 'roof-segment') return [] - const roofType = node.type === 'roof-segment' ? node.roofType : 'gable' - const rows: XRSettingActionRow[] = getRoofFootprintSources(roofType).map((source) => ({ - action: { - label: source.value === 'draw' ? 'Draw Footprint' : `Create from ${source.label}`, - onClick: () => activateRoofFootprintSource(source.value), - } satisfies ParamAction, - id: `roof-source-${source.value}`, - kind: 'action', - label: source.value === 'draw' ? 'Draw Footprint' : `Create from ${source.label}`, - })) - - rows.push({ - action: { - label: 'Draw Segment', - onClick: () => { - triggerSFX('sfx:item-pick') - const editor = useEditor.getState() - editor.setTool('roof') - if (editor.mode !== 'build') editor.setMode('build') - }, - } satisfies ParamAction, - id: 'roof-draw-segment', - kind: 'action', - label: 'Draw Segment', - }) - - for (const feature of collectRoofFeatures()) { - rows.push({ - action: { - label: `Add ${feature.label}`, - onClick: () => activateRoofFeatureTool(feature), - } satisfies ParamAction, - id: `roof-feature-${feature.id}`, - kind: 'action', - label: `Add ${feature.label}`, - }) - } - - return rows -} - -type RoofSpatialAction = { - id: string - label: string - onClick: () => void -} - -function RoofSpatialSettings({ roof }: { roof: RoofNode }) { - const setSelection = useViewer((state) => state.setSelection) - const setTool = useEditor((state) => state.setTool) - const setMode = useEditor((state) => state.setMode) - const roofDefaults = useEditor((state) => state.toolDefaults.roof) - const registryVersion = useRegistryVersion() - const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) - const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' - const actionIds = useScene( - useShallow((state) => { - const segmentIds = (roof.children ?? []).filter( - (id) => state.nodes[id as AnyNodeId]?.type === 'roof-segment', - ) - const segmentIdSet = new Set(segmentIds) - const accessoryIds = Object.values(state.nodes) - .filter((node) => node?.parentId && segmentIdSet.has(node.parentId as AnyNodeId)) - .map((node) => node!.id) - return [...segmentIds, ...accessoryIds] - }), - ) - const actions = useMemo(() => { - const nodes = useScene.getState().nodes - let segmentIndex = 0 - return actionIds.flatMap((id) => { - const node = nodes[id as AnyNodeId] - if (!node) return [] - if (node.type === 'roof-segment') { - segmentIndex += 1 - return [ - { - id: `segment-${node.id}`, - label: `Segment ${segmentIndex}: ${node.roofType}`, - onClick: () => setSelection({ selectedIds: [node.id as AnyNodeId] }), - }, - ] - } - return [ - { - id: `accessory-${node.id}`, - label: `${node.name || node.type}`, - onClick: () => setSelection({ selectedIds: [node.id as AnyNodeId] }), - }, - ] - }) - }, [actionIds, setSelection]) - const paginationKey = useXRWandPanelSettings((state) => state.settingsContextKey) - const paginationPage = useXRWandPanelSettings((state) => state.settingsPage) - const setSettingsNavigation = useXRWandPanelSettings((state) => state.setSettingsNavigation) - const rows = useMemo(() => { - void registryVersion - return [ - ...getRoofFootprintSources(roofType).map((source) => ({ - id: `draw-from-${source.value}`, - label: source.value === 'draw' ? 'Draw Footprint' : `Create from ${source.label}`, - onClick: () => activateRoofFootprintSource(source.value), - })), - { - id: 'draw-segment', - label: 'Draw Segment', - onClick: () => { - triggerSFX('sfx:item-pick') - setTool('roof') - if (useEditor.getState().mode !== 'build') setMode('build') - }, - }, - ...actions, - ...collectRoofFeatures().map((feature) => ({ - id: `add-${feature.id}`, - label: `Add ${feature.label}`, - onClick: () => { - activateRoofFeatureTool(feature) - }, - })), - ] - }, [actions, registryVersion, roofType, setMode, setTool]) - const contextKey = `node:${roof.id}:roof-actions` - const page = paginationKey === contextKey ? paginationPage : 0 - const current = getPage(rows, page, ROWS_PER_PAGE) - - return ( - <> - {current.items.map((row, index) => ( - - - - {row.label} - - - - ))} - setSettingsNavigation(contextKey, nextPage)} - page={current.currentPage} - pageCount={current.pageCount} - /> - - ) -} - -function cycleOption(options: readonly unknown[], current: unknown, direction: -1 | 1) { - if (options.length === 0) return undefined - const index = options.indexOf(current) - const base = index < 0 ? (direction === 1 ? -1 : 0) : index - return options[(base + direction + options.length) % options.length] -} - -function formatValue(value: unknown) { - if (typeof value === 'string') return value || 'None' - if (typeof value === 'boolean') return value ? 'On' : 'Off' - if (typeof value === 'number') return String(Number(value.toFixed(3))) - return value == null ? 'None' : 'Assigned' -} - -function FieldControl({ - context, - materials, - onChange, - referenceNodes, - row, -}: { - context: XRSettingsContext - materials: ReturnType - onChange: (row: XRSettingFieldRow, value: unknown) => void - referenceNodes: AnyNode[] - row: XRSettingFieldRow -}) { - let value = readXRSettingValue(context, row) - const name = `xr-setting-${String(row.field.key)}${row.axis == null ? '' : `-${row.axis}`}` - - if (row.field.kind === 'number' || row.field.kind === 'vec3') { - const min = row.field.kind === 'number' ? (row.field.min ?? -1000) : -1000 - const max = row.field.kind === 'number' ? (row.field.max ?? 1000) : 1000 - const numericValue = Math.max(min, Math.min(max, typeof value === 'number' ? value : min)) - return ( - onChange(row, next)} - step={row.field.kind === 'number' ? (row.field.step ?? 0.1) : 0.1} - unit={row.field.kind === 'number' ? row.field.unit : undefined} - value={numericValue} - /> - ) - } - if (row.field.kind === 'boolean') { - return ( - onChange(row, value !== true)} - value={value === true ? 'On' : 'Off'} - /> - ) - } - - let options: readonly unknown[] = [] - let displayValue = formatValue(value) - let mapValue = (next: unknown) => next - if (row.field.kind === 'enum') options = row.field.options - if (row.field.kind === 'color') options = XR_COLORS - if (row.field.kind === 'material') { - options = materials.map((material) => material.id) - const selectedId = getLibraryMaterialIdFromRef(value as never) - displayValue = materials.find((material) => material.id === selectedId)?.label ?? 'Default' - mapValue = (next) => toLibraryMaterialRef(String(next)) - value = selectedId - } - if (row.field.kind === 'ref') { - const refKind = row.field.refKind - const references = referenceNodes.filter((node) => node.type === refKind) - options = [null, ...references.map((node) => node.id)] - const selected = references.find((node) => node.id === value) - displayValue = selected - ? String((selected as AnyNode & { name?: string }).name ?? selected.type) - : 'None' - } - if (row.field.kind === 'custom') { - return - } - - const change = (direction: -1 | 1) => { - const next = cycleOption(options, value, direction) - if (next !== undefined) onChange(row, mapValue(next)) - } - return ( - change(1)} - previous={() => change(-1)} - value={displayValue} - /> - ) -} - -function ToolChipControl({ row }: { row: XRSettingToolChipRow }) { - const { chip } = row.hint - const value = useSyncExternalStore(chip.subscribe, chip.value, chip.value) - return ( - - ) -} - -function DefaultSettings() { - const mode = useEditor((state) => state.mode) - const interactionIdle = useInteractionScope((state) => state.scope.kind === 'idle') - const playerMode = useXRPlayerMode((state) => state.mode) - const panelScale = useXRWandPanelSettings((state) => state.panelScale) - const setPanelScale = useXRWandPanelSettings((state) => state.setPanelScale) - const gridSnapStep = useEditor((state) => state.gridSnapStep) - const cycleGridSnapStep = useEditor((state) => state.cycleGridSnapStep) - const wallSnappingMode = useEditor((state) => state.snappingModeByContext.wall) - const setSnappingMode = useEditor((state) => state.setSnappingMode) - const selectedBuildingId = useViewer((state) => state.selection.buildingId) - const activeLevelId = useViewer((state) => state.selection.levelId) - const setSelection = useViewer((state) => state.setSelection) - const createNode = useScene((state) => state.createNode) - const deleteNode = useScene((state) => state.deleteNode) - const settingsPage = useXRWandPanelSettings((state) => state.settingsPage) - const settingsContextKey = useXRWandPanelSettings((state) => state.settingsContextKey) - const setSettingsNavigation = useXRWandPanelSettings((state) => state.setSettingsNavigation) - const canUndo = useSyncExternalStore( - subscribeHistoryCommandState, - () => getHistoryCommandState().canUndo, - () => false, - ) - const canRedo = useSyncExternalStore( - subscribeHistoryCommandState, - () => getHistoryCommandState().canRedo, - () => false, - ) - const resolvedBuildingId = useScene((state) => { - if (selectedBuildingId && state.nodes[selectedBuildingId]?.type === 'building') { - return selectedBuildingId - } - return ( - Object.values(state.nodes).find((node) => node?.type === 'building') as - | BuildingNode - | undefined - )?.id - }) - const levels = useScene( - useShallow((state) => { - const building = resolvedBuildingId ? state.nodes[resolvedBuildingId] : undefined - if (building?.type !== 'building') return [] as LevelNode[] - return building.children - .map((id) => state.nodes[id]) - .filter((node): node is LevelNode => node?.type === 'level') - .sort((a, b) => a.level - b.level) - }), - ) - const activeLevel = levels.find((level) => level.id === activeLevelId) ?? levels[0] - const cycleFloor = () => { - if (!activeLevel) return - const index = levels.findIndex((level) => level.id === activeLevel.id) - const next = levels[(index + 1) % levels.length] - if (next) setSelection({ buildingId: resolvedBuildingId, levelId: next.id }) - } - - const addFloor = () => { - if (!resolvedBuildingId) return - const level = levels.length === 0 ? 0 : Math.max(...levels.map((entry) => entry.level)) + 1 - const newLevel = LevelNode.parse({ - level, - height: DEFAULT_LEVEL_HEIGHT, - children: [], - parentId: resolvedBuildingId, - }) - createNode(newLevel, resolvedBuildingId as AnyNodeId) - setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) - } - - const addBasement = () => { - if (!resolvedBuildingId) return - const level = levels.length === 0 ? -1 : Math.min(...levels.map((entry) => entry.level)) - 1 - const newLevel = LevelNode.parse({ - level, - height: DEFAULT_LEVEL_HEIGHT, - children: [], - parentId: resolvedBuildingId, - }) - createNode(newLevel, resolvedBuildingId as AnyNodeId) - setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) - } - - const removeFloor = () => { - if (!(activeLevel && activeLevel.level !== 0)) return - const index = levels.findIndex((level) => level.id === activeLevel.id) - const fallback = levels[index - 1] ?? levels[index + 1] - deleteNode(activeLevel.id) - setSelection({ - buildingId: resolvedBuildingId, - levelId: fallback?.id ?? null, - }) - } - - const page = settingsContextKey === DEFAULT_SETTINGS_CONTEXT_KEY ? settingsPage : 0 - const setPage = (nextPage: number) => - setSettingsNavigation(DEFAULT_SETTINGS_CONTEXT_KEY, nextPage) - - return ( - <> - - - Undo - - - - - Redo - - - - - Reset view - - - {page === 0 ? ( - <> - - - - - - - Add floor - - - - - Add basement - - - - - - - Remove selected floor - - - - - - - - ) : ( - <> - - - - - - - - - - - setSnappingMode('wall', cycleSnappingModeIn('wall', wallSnappingMode))} - value={getSnappingModeLabel(wallSnappingMode)} - /> - - - )} - - - ) -} - -export function XRSettingsPanel() { - const paginationKey = useXRWandPanelSettings((state) => state.settingsContextKey) - const paginationPage = useXRWandPanelSettings((state) => state.settingsPage) - const setSettingsNavigation = useXRWandPanelSettings((state) => state.setSettingsNavigation) - const mode = useEditor((state) => state.mode) - const tool = useEditor((state) => state.tool) - const toolDefaults = useEditor((state) => - state.tool ? state.toolDefaults[state.tool] : undefined, - ) - const setToolDefaults = useEditor((state) => state.setToolDefaults) - const selectedId = useViewer((state) => - state.selection.selectedIds.length === 1 ? state.selection.selectedIds[0] : undefined, - ) - const selectedNode = useScene((state) => - selectedId ? state.nodes[selectedId as AnyNodeId] : undefined, - ) - const deleteNode = useScene((state) => state.deleteNode) - const setSelection = useViewer((state) => state.setSelection) - const nodes = useScene((state) => state.nodes) - const materialVersion = useSyncExternalStore( - subscribeLibraryMaterials, - getLibraryMaterialsVersion, - getLibraryMaterialsVersion, - ) - const materials = useMemo(() => { - void materialVersion - return MATERIAL_CATEGORIES.flatMap((category) => getMaterialsForCategory(category)) - }, [materialVersion]) - const referenceNodes = useMemo(() => Object.values(nodes).filter(Boolean) as AnyNode[], [nodes]) - const context = useMemo( - () => resolveXRSettingsContext({ mode, selectedNode, tool, toolDefaults }), - [mode, selectedNode, tool, toolDefaults], - ) - const rows = useMemo( - () => - context ? [...collectRoofActionRows(context.node), ...collectXRSettingRows(context)] : [], - [context], - ) - const contextKey = context?.key ?? 'default' - const page = paginationKey === contextKey ? paginationPage : 0 - const current = getPage(rows, page, ROWS_PER_PAGE) - const setPage = (nextPage: number) => setSettingsNavigation(contextKey, nextPage) - const deleteSelectedNode = () => { - if (!(selectedId && selectedNode && context?.source === 'node')) return - if (context.definition.capabilities.deletable === false) return - emitDeleteSFX(selectedNode.type) - setSelection({ selectedIds: [] }) - deleteNode(selectedId as AnyNodeId) - } - - const update = (row: XRSettingFieldRow, value: unknown) => { - if (!context) return - const patch = createXRSettingPatch(context, row, value) - if (context.source === 'node') { - commitParametricNodeFields(context.node.id as AnyNodeId, patch) - } else if (context.tool) { - setToolDefaults(context.tool, { ...toolDefaults, ...patch }) - } - } - - if (mode === 'terrain-sculpt') return - - return ( - - - {!context ? ( - - ) : context.node.type === 'roof' && context.source === 'node' ? ( - - ) : ( - <> - {current.items.map((row, index) => ( - - {row.kind === 'field' ? ( - - ) : row.kind === 'action' ? ( - - row.action.onClick( - useScene.getState().nodes[context.node.id as AnyNodeId] as AnyNode, - ) - } - position={[0, 0, 0]} - size={[0.7, 0.075]} - > - - {row.label} - - - ) : ( - - )} - - ))} - {rows.length === 0 && ( - No spatial settings are exposed for this item yet. - )} - {current.pageCount > 1 && ( - - )} - - )} - - ) -} diff --git a/apps/editor/components/xr/wand-panel/spatial-controls.tsx b/apps/editor/components/xr/wand-panel/spatial-controls.tsx deleted file mode 100644 index a38497cbe2..0000000000 --- a/apps/editor/components/xr/wand-panel/spatial-controls.tsx +++ /dev/null @@ -1,476 +0,0 @@ -'use client' - -import { EDITOR_LAYER } from '@pascal-app/editor' -import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' -import { DoubleSide, Shape } from 'three' -import { XR_WAND_PANEL_LAYOUT } from './panel-layout' -import { SpatialLine, shapeLinePoints } from './spatial-line' -import { SpatialText } from './spatial-text' -import { XR_WAND_THEME } from './theme' - -declare global { - var __pascalXRHoveredTarget: string | undefined - var __pascalXRLastPointerEvent: string | undefined -} - -const { accent, accentLine, border, disabled: disabledColor, muted, panel, text } = XR_WAND_THEME -const LEFT_CHEVRON = [ - [0.012, 0.018, 0.012], - [-0.012, 0, 0.012], - [0.012, -0.018, 0.012], -] as [number, number, number][] -const RIGHT_CHEVRON = LEFT_CHEVRON.map(([x, y, z]) => [-x, y, z] as [number, number, number]) - -function roundedShape(width: number, height: number, radius = 0.018) { - const shape = new Shape() - const halfWidth = width / 2 - const halfHeight = height / 2 - const r = Math.min(radius, halfWidth, halfHeight) - shape.moveTo(-halfWidth + r, -halfHeight) - shape.lineTo(halfWidth - r, -halfHeight) - shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) - shape.lineTo(halfWidth, halfHeight - r) - shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) - shape.lineTo(-halfWidth + r, halfHeight) - shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) - shape.lineTo(-halfWidth, -halfHeight + r) - shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) - shape.closePath() - return shape -} - -export function SpatialButton({ - children, - color = text, - disabled = false, - name, - onClick, - position, - selected = false, - size, -}: { - children?: ReactNode - color?: string - disabled?: boolean - name?: string - onClick?: () => void - position: [number, number, number] - selected?: boolean - size: [number, number] -}) { - const [hovered, setHovered] = useState(false) - const [pressed, setPressed] = useState(false) - const hoverLeaveTimer = useRef | null>(null) - const shape = useMemo(() => roundedShape(size[0], size[1]), [size]) - const points = useMemo(() => shapeLinePoints(shape), [shape]) - - useEffect( - () => () => { - if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current) - }, - [], - ) - - return ( - - { - event.stopPropagation() - if (process.env.NODE_ENV === 'development') { - globalThis.__pascalXRLastPointerEvent = `click:${name ?? ''}` - } - if (!disabled) onClick?.() - }} - onPointerCancel={(event) => { - event.object.releasePointerCapture?.(event.pointerId) - setPressed(false) - }} - onPointerDown={(event) => { - event.stopPropagation() - event.object.setPointerCapture?.(event.pointerId) - if (process.env.NODE_ENV === 'development') { - globalThis.__pascalXRLastPointerEvent = `down:${name ?? ''}` - } - if (!disabled) setPressed(true) - }} - onPointerEnter={() => { - if (disabled) return - if (hoverLeaveTimer.current) clearTimeout(hoverLeaveTimer.current) - setHovered(true) - if (process.env.NODE_ENV === 'development') globalThis.__pascalXRHoveredTarget = name - }} - onPointerLeave={() => { - hoverLeaveTimer.current = setTimeout(() => setHovered(false), 75) - setPressed(false) - if (globalThis.__pascalXRHoveredTarget === name) { - globalThis.__pascalXRHoveredTarget = undefined - } - }} - onPointerUp={(event) => { - event.stopPropagation() - event.object.releasePointerCapture?.(event.pointerId) - if (process.env.NODE_ENV === 'development') { - globalThis.__pascalXRLastPointerEvent = `up:${name ?? ''}` - } - setPressed(false) - }} - position={[0, 0, 0.004]} - > - - - - - {children} - - ) -} - -export function PanelFace() { - const shape = useMemo( - () => - roundedShape( - XR_WAND_PANEL_LAYOUT.faceWidth, - XR_WAND_PANEL_LAYOUT.faceHeight, - XR_WAND_PANEL_LAYOUT.faceCornerRadius, - ), - [], - ) - const points = useMemo(() => shapeLinePoints(shape), [shape]) - return ( - <> - - - - - - - ) -} - -export function PanelHeader({ - mark, - onDelete, - title, -}: { - mark?: string - onDelete?: () => void - title: string -}) { - return ( - <> - - {title} - - {mark && ( - - {mark} - - )} - {onDelete && ( - - - Delete - - - )} - - - ) -} - -export function PanelHint({ - children, - position = [0, -0.37, 0.012], -}: { - children: ReactNode - position?: [number, number, number] -}) { - return ( - - {children} - - ) -} - -export function PageArrows({ - name, - onChange, - page, - pageCount, -}: { - name: string - onChange: (page: number) => void - page: number - pageCount: number -}) { - return ( - - onChange(page - 1)} - position={[-0.27, 0, 0]} - size={[0.1, 0.065]} - > - - - - {page + 1} / {pageCount} - - = pageCount - 1} - name={`${name}-next-page`} - onClick={() => onChange(page + 1)} - position={[0.27, 0, 0]} - size={[0.1, 0.065]} - > - = pageCount - 1 ? disabledColor : text} - lineWidth={1.5} - points={RIGHT_CHEVRON} - /> - - - ) -} - -export function SettingStepper({ - label, - max, - min, - name, - onChange, - step, - unit, - value, -}: { - label: string - max: number - min: number - name: string - onChange: (value: number) => void - step: number - unit?: string - value: number -}) { - return ( - - - {label} - - onChange(Math.max(min, value - step))} - position={[0.1, 0, 0]} - size={[0.085, 0.07]} - > - - − - - - - {Number(value.toFixed(3))} - {unit ? ` ${unit}` : ''} - - onChange(Math.min(max, value + step))} - position={[0.34, 0, 0]} - size={[0.085, 0.07]} - > - - + - - - - ) -} - -export function SettingChoice({ - label, - name, - onClick, - value, -}: { - label: string - name: string - onClick?: () => void - value: string -}) { - return ( - - - {label} - - - - {value} - - - - ) -} - -export function SettingCycle({ - label, - name, - next, - previous, - value, -}: { - label: string - name: string - next: () => void - previous: () => void - value: string -}) { - return ( - - - {label} - - - - - - {value} - - - - - - ) -} diff --git a/apps/editor/components/xr/wand-panel/spatial-line.tsx b/apps/editor/components/xr/wand-panel/spatial-line.tsx deleted file mode 100644 index 0dd17fedac..0000000000 --- a/apps/editor/components/xr/wand-panel/spatial-line.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { EDITOR_LAYER } from '@pascal-app/editor' -import { useEffect, useMemo } from 'react' -import { BufferGeometry, LineBasicMaterial, type Shape, Line as ThreeLine, Vector3 } from 'three' - -export function shapeLinePoints(shape: Shape) { - const points = shape.getPoints(6).map(({ x, y }) => [x, y, 0.007] as [number, number, number]) - points.push(points[0]!) - return points -} - -export function SpatialLine({ - color, - lineWidth = 1, - opacity = 1, - points, - renderOrder = 5, - transparent = false, -}: { - color: string - lineWidth?: number - opacity?: number - points: readonly [number, number, number][] - renderOrder?: number - transparent?: boolean -}) { - const line = useMemo( - () => - new ThreeLine( - new BufferGeometry().setFromPoints(points.map(([x, y, z]) => new Vector3(x, y, z))), - new LineBasicMaterial({ - color, - linewidth: lineWidth, - opacity, - transparent: transparent || opacity < 1, - }), - ), - [color, lineWidth, opacity, points, transparent], - ) - - useEffect( - () => () => { - line.geometry.dispose() - line.material.dispose() - }, - [line], - ) - - line.layers.set(EDITOR_LAYER) - line.renderOrder = renderOrder - return undefined} /> -} diff --git a/apps/editor/components/xr/wand-panel/spatial-text.tsx b/apps/editor/components/xr/wand-panel/spatial-text.tsx deleted file mode 100644 index a80feb0204..0000000000 --- a/apps/editor/components/xr/wand-panel/spatial-text.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client' - -import { EDITOR_LAYER } from '@pascal-app/editor' -import { Children, type ReactNode, useEffect, useMemo } from 'react' -import { CanvasTexture, SRGBColorSpace } from 'three' - -const PIXELS_PER_METER = 2048 -const FONT_FAMILY = 'Inter, ui-sans-serif, system-ui, sans-serif' - -function wrapLines(context: CanvasRenderingContext2D, text: string, maxWidth?: number) { - const paragraphs = text.split('\n') - if (!maxWidth) return paragraphs - - const lines: string[] = [] - for (const paragraph of paragraphs) { - const words = paragraph.split(/\s+/).filter(Boolean) - if (words.length === 0) { - lines.push('') - continue - } - - let line = words[0]! - for (const word of words.slice(1)) { - const candidate = `${line} ${word}` - if (context.measureText(candidate).width <= maxWidth) line = candidate - else { - lines.push(line) - line = word - } - } - lines.push(line) - } - return lines -} - -export function SpatialText({ - anchorX = 'center', - anchorY = 'middle', - children, - color, - fontSize, - maxWidth, - position, - renderOrder = 6, - textAlign = 'center', -}: { - anchorX?: 'center' | 'left' | 'right' - anchorY?: 'bottom' | 'middle' | 'top' - children: ReactNode - color: string - fontSize: number - maxWidth?: number - position: [number, number, number] - renderOrder?: number - textAlign?: 'center' | 'left' | 'right' -}) { - const text = Children.toArray(children).join('') - const rendered = useMemo(() => { - const canvas = document.createElement('canvas') - const context = canvas.getContext('2d') - if (!context) return null - - const fontPixels = Math.max(12, Math.round(fontSize * PIXELS_PER_METER)) - const lineHeight = Math.ceil(fontPixels * 1.2) - const padding = Math.ceil(fontPixels * 0.12) - context.font = `600 ${fontPixels}px ${FONT_FAMILY}` - const lines = wrapLines( - context, - text, - maxWidth ? Math.round(maxWidth * PIXELS_PER_METER) : undefined, - ) - const measuredWidth = Math.max(1, ...lines.map((line) => context.measureText(line).width)) - canvas.width = Math.ceil(measuredWidth + padding * 2) - canvas.height = Math.ceil(lines.length * lineHeight + padding * 2) - - context.font = `600 ${fontPixels}px ${FONT_FAMILY}` - context.fillStyle = color - context.textAlign = textAlign - context.textBaseline = 'top' - const x = - textAlign === 'left' - ? padding - : textAlign === 'right' - ? canvas.width - padding - : canvas.width / 2 - lines.forEach((line, index) => { - context.fillText(line, x, padding + index * lineHeight) - }) - - const texture = new CanvasTexture(canvas) - texture.colorSpace = SRGBColorSpace - return { - height: canvas.height / PIXELS_PER_METER, - texture, - width: canvas.width / PIXELS_PER_METER, - } - }, [color, fontSize, maxWidth, text, textAlign]) - - useEffect(() => () => rendered?.texture.dispose(), [rendered]) - if (!rendered) return null - - const offsetX = - anchorX === 'left' ? rendered.width / 2 : anchorX === 'right' ? -rendered.width / 2 : 0 - const offsetY = - anchorY === 'top' ? -rendered.height / 2 : anchorY === 'bottom' ? rendered.height / 2 : 0 - - return ( - undefined} - > - - - - ) -} diff --git a/apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx b/apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx deleted file mode 100644 index cc0af3b803..0000000000 --- a/apps/editor/components/xr/wand-panel/terrain-settings-panel.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client' - -import { type SiteNode, type TerrainVerb, useScene } from '@pascal-app/core' -import { brushRadiusRange, flattenSite, resetSiteTerrain, useEditor } from '@pascal-app/editor' -import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' -import { getPage } from './panel-layout' -import { - PageArrows, - PanelHeader, - SettingChoice, - SettingCycle, - SettingStepper, -} from './spatial-controls' - -const TERRAIN_VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth'] -const ROWS_PER_PAGE = 5 - -export function XRTerrainSettingsPanel() { - const page = useXRWandPanelSettings((state) => state.terrainPage) - const setPage = useXRWandPanelSettings((state) => state.setTerrainPage) - const verb = useEditor((state) => state.terrainVerb) - const setVerb = useEditor((state) => state.setTerrainVerb) - const brush = useEditor((state) => state.terrainBrush) - const setBrush = useEditor((state) => state.setTerrainBrush) - const flattenTarget = useEditor((state) => state.terrainFlattenTarget) - const setFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget) - const sampling = useEditor((state) => state.terrainSampling) - const setSampling = useEditor((state) => state.setTerrainSampling) - const site = useScene((state) => { - const root = state.rootNodeIds[0] - const node = root ? state.nodes[root] : undefined - return node?.type === 'site' ? (node as SiteNode) : null - }) - const [minRadius, maxRadius] = brushRadiusRange(site) - const verbIndex = TERRAIN_VERBS.indexOf(verb) - const cycleVerb = (direction: -1 | 1) => { - const next = - TERRAIN_VERBS[(verbIndex + direction + TERRAIN_VERBS.length) % TERRAIN_VERBS.length] - if (next) setVerb(next) - } - - const rows = [ - cycleVerb(1)} - previous={() => cycleVerb(-1)} - value={verb.charAt(0).toUpperCase() + verb.slice(1)} - />, - setBrush({ radius })} - step={0.5} - unit="m" - value={Math.max(minRadius, Math.min(maxRadius, brush.radius))} - />, - setBrush({ strength })} - step={0.05} - value={brush.strength} - />, - setBrush({ falloff })} - step={0.05} - value={brush.falloff} - />, - setBrush({ shape: brush.shape === 'round' ? 'square' : 'round' })} - value={brush.shape === 'round' ? 'Round' : 'Square'} - />, - ...(verb === 'flatten' - ? [ - , - setSampling(!sampling)} - value={sampling ? 'Armed' : 'Off'} - />, - ] - : []), - flattenSite(site, flattenTarget ?? 0) : undefined} - value="Level" - />, - resetSiteTerrain(site) : undefined} - value={site?.terrain ? 'Clear' : 'Empty'} - />, - ] - const current = getPage(rows, page, ROWS_PER_PAGE) - - return ( - - - {current.items.map((row, index) => ( - - {row} - - ))} - {current.pageCount > 1 && ( - - )} - - ) -} diff --git a/apps/editor/components/xr/wand-panel/theme.ts b/apps/editor/components/xr/wand-panel/theme.ts deleted file mode 100644 index f8f514102b..0000000000 --- a/apps/editor/components/xr/wand-panel/theme.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const XR_WAND_THEME = { - accent: '#0ea5e9', - accentLine: '#38bdf8', - border: '#52525b', - disabled: '#52525b', - muted: '#a1a1aa', - panel: '#171717', - text: '#fafafa', -} as const diff --git a/apps/editor/components/xr/wand-panel/wand-panel.tsx b/apps/editor/components/xr/wand-panel/wand-panel.tsx deleted file mode 100644 index d24a26bc77..0000000000 --- a/apps/editor/components/xr/wand-panel/wand-panel.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client' - -import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' -import { XRBuildPanel } from './build-panel' -import { XRPaintPanel } from './paint-panel' -import { - resolveWandPanelFacePose, - XR_WAND_PANEL_INPUT_NAME, - XR_WAND_PANEL_LAYOUT, -} from './panel-layout' -import { XRSettingsPanel } from './settings-panel' -import { PanelFace } from './spatial-controls' - -export function XRWandPanel({ handedness = 'left' }: { handedness?: XRHandedness }) { - const panelScale = useXRWandPanelSettings((state) => state.panelScale) - const panels = [ - , - , - , - ] - - return ( - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onPointerOver={(event) => event.stopPropagation()} - onPointerUp={(event) => event.stopPropagation()} - pointerEventsOrder={100} - pointerEventsType={{ deny: 'grab' }} - scale={panelScale} - > - {panels.map((panel, index) => { - const pose = resolveWandPanelFacePose(index, handedness) - return ( - - - {panel} - - ) - })} - - ) -} diff --git a/apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx b/apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx deleted file mode 100644 index e4a8f1d867..0000000000 --- a/apps/editor/components/xr/wand-panel/xr-wand-input-overlay.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import { useXRInputSourceStateContext, XRSpace } from '@react-three/xr' -import { XR_WAND_PANEL_LAYOUT } from './panel-layout' -import { XRWandPanel } from './wand-panel' - -export function XRWandInputOverlay({ type }: { type: 'controller' | 'hand' }) { - const state = useXRInputSourceStateContext(type) - const handedness = state.inputSource.handedness - if (handedness !== 'left') return null - - if (type === 'controller') { - return ( - - - - - - ) - } - - return ( - - - - - - ) -} diff --git a/apps/editor/components/xr/xr-editor-input-bridge.tsx b/apps/editor/components/xr/xr-editor-input-bridge.tsx deleted file mode 100644 index f61baacdda..0000000000 --- a/apps/editor/components/xr/xr-editor-input-bridge.tsx +++ /dev/null @@ -1,721 +0,0 @@ -'use client' - -import { - type AnyNodeId, - advanceStroke, - applyHeightPatch, - beginStroke, - type EventSuffix, - emitter, - type GridEvent, - minBrushRadius, - type NodeEvent, - raycastTerrain, - type SiteNode, - sceneRegistry, - surfaceHeightAt, - type TerrainField, - type TerrainStroke, - terrainFieldOf, - useLiveTerrain, - useScene, - type WallEvent, -} from '@pascal-app/core' -import { - cancelActiveTool, - canDirectMoveNode, - clipTerrainPatchToSite, - commitStroke, - createEditorApi, - EDITOR_GRID_INPUT_NAME, - getSpatialPointerId, - resolveFlattenTarget, - sculptFieldForSite, - spatialPointerInput, - terrainPointInsideSite, - useEditor, - useInteractionScope, -} from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useFrame, useThree } from '@react-three/fiber' -import { useXR } from '@react-three/xr' -import { type MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react' -import { - BufferGeometry, - Float32BufferAttribute, - Line, - LineBasicMaterial, - type Object3D, - Plane, - Quaternion, - Raycaster, - Vector3, -} from 'three' -import { - didXRButtonPressStart, - isXRCancelPressed, - pulseXRInputSource, - replayXRWallOpeningRelease, - resolveXRReleaseAction, - selectPrimaryXRInputSource, - shouldReleaseCapturedXRInput, - shouldRouteXRMove, - XRSelectReleaseGuard, -} from '@/lib/xr/editor-input' -import { applyXRReferenceSpaceRayToWorld, setObjectFloorPlane } from '@/lib/xr/reference-space-ray' -import { XR_WAND_PANEL_INPUT_NAME } from './wand-panel/panel-layout' - -type XRGridNativeEvent = { - altKey: false - button: 0 - buttons: number - ctrlKey: false - detail: number - metaKey: false - pointerId: number - pointerType: 'xr' - shiftKey: false - stopImmediatePropagation: () => void - stopPropagation: () => void - target: HTMLCanvasElement - timeStamp: number -} - -type XRTerrainFocus = { radius: number; siteId: SiteNode['id']; x: number; z: number } -const TERRAIN_RING_SEGMENTS = 64 - -const xrInputSourceKey = (source: XRInputSource) => - `${source.handedness}:${source.targetRayMode}:${Boolean(source.hand)}` - -const sameXRInputSource = (a: XRInputSource | null, b: XRInputSource | null) => - a === b || (a != null && b != null && xrInputSourceKey(a) === xrInputSourceKey(b)) - -function XRTerrainBrushCursor({ focusRef }: { focusRef: MutableRefObject }) { - const mode = useEditor((state) => state.mode) - const shape = useEditor((state) => state.terrainBrush.shape) - const verb = useEditor((state) => state.terrainVerb) - const geometry = useMemo(() => { - const result = new BufferGeometry() - result.setAttribute( - 'position', - new Float32BufferAttribute(new Float32Array((TERRAIN_RING_SEGMENTS + 1) * 3), 3), - ) - return result - }, []) - const line = useMemo(() => { - const result = new Line( - geometry, - new LineBasicMaterial({ color: '#38bdf8', depthTest: false, depthWrite: false }), - ) - result.frustumCulled = false - result.name = 'xr-terrain-brush-cursor' - result.raycast = () => undefined - result.renderOrder = 30 - return result - }, [geometry]) - - useEffect( - () => () => { - geometry.dispose() - line.material.dispose() - }, - [geometry, line], - ) - useFrame(() => { - const focus = focusRef.current - line.visible = mode === 'terrain-sculpt' && focus !== null - if (!(line.visible && focus)) return - const site = useScene.getState().nodes[focus.siteId] - if (site?.type !== 'site') return - const field = - useLiveTerrain.getState().strokeOf(site.id)?.field ?? - terrainFieldOf(site) ?? - sculptFieldForSite(site) - const positions = geometry.getAttribute('position') - for (let index = 0; index <= TERRAIN_RING_SEGMENTS; index += 1) { - const angle = (index / TERRAIN_RING_SEGMENTS) * Math.PI * 2 - const cos = Math.cos(angle) - const sin = Math.sin(angle) - const scale = - shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1 - const x = focus.x + cos * focus.radius * scale - const z = focus.z + sin * focus.radius * scale - positions.setXYZ(index, x, surfaceHeightAt(field, x, z) + 0.02, z) - } - positions.needsUpdate = true - geometry.computeBoundingSphere() - line.material.color.set(verb === 'raise' ? '#22c55e' : verb === 'lower' ? '#ef4444' : '#38bdf8') - }) - - return -} - -function isXRNodePointer(event: NodeEvent): boolean { - return getSpatialPointerId(event.nativeEvent) != null -} - -export function XREditorInputBridge() { - const session = useXR((state) => state.session) - const origin = useXR((state) => state.origin) - const scene = useThree((state) => state.scene) - const gl = useThree((state) => state.gl) - // Logical XR pointer capture: the source that starts a scene press owns its - // move/up stream until selectend, even when its ray crosses the wand. - const capturedInputSource = useRef(null) - const lastXRWallEvent = useRef(null) - const lastSyntheticWallEvent = useRef(null) - const terrainInputSources = useRef(new Set()) - const terrainStroke = useRef<{ - field: TerrainField - siteId: SiteNode['id'] - source: XRInputSource - stroke: TerrainStroke - } | null>(null) - const terrainFocus = useRef(null) - const panelInputSources = useRef(new Set()) - const cancelPressed = useRef(false) - const pointerIds = useRef(new WeakMap()) - const nextPointerId = useRef(10_000) - const raycaster = useRef(new Raycaster()) - const rayOrigin = useRef(new Vector3()) - const rayDirection = useRef(new Vector3()) - const rayRotation = useRef(new Quaternion()) - const gridPlane = useRef(new Plane()) - const gridPlaneNormal = useRef(new Vector3()) - const gridPlanePoint = useRef(new Vector3()) - const selectReleaseGuard = useRef(new XRSelectReleaseGuard()) - - const activeSite = useCallback(() => { - const state = useScene.getState() - const node = state.rootNodeIds[0] ? state.nodes[state.rootNodeIds[0]] : undefined - return node?.type === 'site' ? (node as SiteNode) : null - }, []) - - const pointerIdFor = useCallback((source: XRInputSource) => { - const existing = pointerIds.current.get(source) - if (existing !== undefined) return existing - const next = nextPointerId.current++ - pointerIds.current.set(source, next) - return next - }, []) - - const updateRay = useCallback( - (frame: XRFrame, source: XRInputSource): boolean => { - const referenceSpace = gl.xr.getReferenceSpace() - if (!referenceSpace) return false - const pose = frame.getPose(source.targetRaySpace, referenceSpace) - if (!(origin && pose)) return false - const { position, orientation } = pose.transform - origin.updateWorldMatrix(true, false) - rayOrigin.current.set(position.x, position.y, position.z) - rayRotation.current.set(orientation.x, orientation.y, orientation.z, orientation.w) - rayDirection.current.set(0, 0, -1).applyQuaternion(rayRotation.current) - applyXRReferenceSpaceRayToWorld(rayOrigin.current, rayDirection.current, origin.matrixWorld) - raycaster.current.ray.set(rayOrigin.current, rayDirection.current) - raycaster.current.layers.enableAll() - return true - }, - [gl, origin], - ) - - const isWandPanelHit = useCallback( - (frame: XRFrame, source: XRInputSource): boolean => { - const panel = scene.getObjectByName(XR_WAND_PANEL_INPUT_NAME) - if (!(panel && updateRay(frame, source))) return false - panel.updateWorldMatrix(true, true) - return raycaster.current.intersectObject(panel, true).length > 0 - }, - [scene, updateRay], - ) - - const terrainPoint = useCallback( - (frame: XRFrame, source: XRInputSource, field: TerrainField, site: SiteNode) => { - if (!updateRay(frame, source)) return null - const origin = rayOrigin.current - const direction = rayDirection.current - const hit = raycastTerrain( - field, - [origin.x, origin.y, origin.z], - [direction.x, direction.y, direction.z], - ) - if (hit && terrainPointInsideSite(site, hit.x, hit.z)) return [hit.x, hit.z] as const - - // A site without persisted terrain has an implicit ground plane. Keep XR - // strokes usable before the first terrain sample exists; the terrain - // raycast only covers the finite heightfield once it has a valid hit. - if (Math.abs(direction.y) < 1e-6) return null - const t = -origin.y / direction.y - if (t < 0) return null - const x = origin.x + direction.x * t - const z = origin.z + direction.z * t - return terrainPointInsideSite(site, x, z) ? ([x, z] as const) : null - }, - [updateRay], - ) - - const abandonTerrainStroke = useCallback(() => { - const active = terrainStroke.current - if (!active) return false - terrainStroke.current = null - useLiveTerrain.getState().end(active.siteId) - return true - }, []) - - const applyTerrainDab = useCallback( - (frame: XRFrame, source: XRInputSource) => { - const active = terrainStroke.current - const site = activeSite() - if (!(active && sameXRInputSource(active.source, source) && site?.id === active.siteId)) - return false - const point = terrainPoint(frame, source, active.stroke.snapshot, site) - if (!point) return false - terrainFocus.current = { - radius: active.stroke.settings.radius, - siteId: site.id, - x: point[0], - z: point[1], - } - const brushPatch = advanceStroke(active.stroke, point[0], point[1]) - if (!brushPatch) return false - const patch = clipTerrainPatchToSite(active.field, brushPatch, site) - active.field = applyHeightPatch(active.field, patch) - useLiveTerrain.getState().advance(active.siteId, active.field, patch) - return true - }, - [activeSite, terrainPoint], - ) - - const startTerrainStroke = useCallback( - (frame: XRFrame, source: XRInputSource) => { - const site = activeSite() - if (!site) return false - const editor = useEditor.getState() - const field = sculptFieldForSite(site) - const point = terrainPoint(frame, source, field, site) - if (!point) return false - if (editor.terrainSampling) { - editor.setTerrainFlattenTarget(resolveFlattenTarget(field, null, point[0], point[1])) - return true - } - const stroke = beginStroke({ - field, - settings: { - ...editor.terrainBrush, - radius: Math.max(editor.terrainBrush.radius, minBrushRadius(field)), - }, - target: - editor.terrainVerb === 'flatten' - ? resolveFlattenTarget(field, editor.terrainFlattenTarget, point[0], point[1]) - : undefined, - verb: editor.terrainVerb, - }) - terrainStroke.current = { field, siteId: site.id, source, stroke } - useLiveTerrain.getState().begin(site.id, field) - applyTerrainDab(frame, source) - return true - }, - [activeSite, applyTerrainDab, terrainPoint], - ) - - const finishTerrainStroke = useCallback((source: XRInputSource) => { - const active = terrainStroke.current - if (!(active && sameXRInputSource(active.source, source))) return false - terrainStroke.current = null - commitStroke(active.siteId, active.field) - useLiveTerrain.getState().end(active.siteId) - return true - }, []) - - const createGridEvent = useCallback( - ( - frame: XRFrame, - source: XRInputSource, - buttons: number, - allowRayFallback = false, - ): GridEvent | null => { - const grid = scene.getObjectByName(EDITOR_GRID_INPUT_NAME) - if (!updateRay(frame, source)) return null - grid?.updateWorldMatrix(true, false) - - const selection = useViewer.getState().selection - const levelMesh = selection.levelId - ? sceneRegistry.nodes.get(selection.levelId as AnyNodeId) - : null - levelMesh?.updateWorldMatrix(true, false) - let levelFloorPoint: Vector3 | null = null - if (levelMesh) { - setObjectFloorPlane( - gridPlane.current, - levelMesh.matrixWorld, - gridPlanePoint.current, - gridPlaneNormal.current, - ) - levelFloorPoint = raycaster.current.ray.intersectPlane(gridPlane.current, new Vector3()) - } - const hit = - !levelFloorPoint && grid?.visible - ? raycaster.current.intersectObject(grid, false)[0] - : undefined - if (!(levelFloorPoint || hit || allowRayFallback)) return null - - const worldPoint = levelFloorPoint ?? hit?.point ?? raycaster.current.ray.at(1, new Vector3()) - const buildingId = selection.buildingId - const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - const localPoint = buildingMesh - ? buildingMesh.worldToLocal(worldPoint.clone()) - : worldPoint.clone() - const nativeEvent: XRGridNativeEvent = { - altKey: false, - button: 0, - buttons, - ctrlKey: false, - detail: 1, - metaKey: false, - pointerId: pointerIdFor(source), - pointerType: 'xr', - shiftKey: false, - stopImmediatePropagation: () => undefined, - stopPropagation: () => undefined, - target: gl.domElement, - timeStamp: performance.now(), - } - return { - localPosition: [localPoint.x, localPoint.y, localPoint.z], - nativeEvent: nativeEvent as never, - position: [worldPoint.x, worldPoint.y, worldPoint.z], - } - }, - [gl, pointerIdFor, scene, updateRay], - ) - - const emitGridEvent = useCallback( - (suffix: EventSuffix, frame: XRFrame, source: XRInputSource, buttons: number): boolean => { - const payload = createGridEvent(frame, source, buttons) - if (!payload) return false - emitter.emit(`grid:${suffix}` as `grid:${EventSuffix}`, payload) - return true - }, - [createGridEvent], - ) - - const emitWallOpeningHover = useCallback( - (frame: XRFrame, source: XRInputSource): boolean => { - if (!updateRay(frame, source)) return false - - let nearest: - | { - distance: number - event: WallEvent - } - | undefined - const nativeEvent = { - button: 0, - buttons: 0, - inputSource: source, - openingHoverBridge: true, - pointerId: pointerIdFor(source), - pointerType: 'xr', - stopImmediatePropagation: () => undefined, - stopPropagation: () => undefined, - target: gl.domElement, - timeStamp: performance.now(), - } - const registeredObjects = new Set(sceneRegistry.nodes.values()) - - for (const node of Object.values(useScene.getState().nodes)) { - if (node?.type !== 'wall') continue - const object = sceneRegistry.nodes.get(node.id) - if (!object) continue - object.updateWorldMatrix(true, true) - const hit = raycaster.current.intersectObject(object, true).find((intersection) => { - let current: Object3D | null = intersection.object - while (current && current !== object) { - if (registeredObjects.has(current)) return false - current = current.parent - } - return current === object - }) - if (!(hit?.face && (!nearest || hit.distance < nearest.distance))) continue - const localPoint = object.worldToLocal(hit.point.clone()) - nearest = { - distance: hit.distance, - event: { - localPosition: [localPoint.x, localPoint.y, localPoint.z], - nativeEvent: nativeEvent as never, - node, - normal: [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z], - object: hit.object, - position: [hit.point.x, hit.point.y, hit.point.z], - stopPropagation: () => undefined, - }, - } - } - - if (!nearest) { - const previous = lastSyntheticWallEvent.current - if (previous) emitter.emit('wall:leave', previous) - lastSyntheticWallEvent.current = null - return false - } - - lastSyntheticWallEvent.current = nearest.event - emitter.emit('wall:move', nearest.event) - return true - }, - [gl, pointerIdFor, updateRay], - ) - - const dispatchWindowPointerEvent = useCallback( - (type: 'pointerup' | 'pointercancel', source: XRInputSource) => { - window.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - button: 0, - pointerId: pointerIdFor(source), - pointerType: 'xr', - }), - ) - }, - [pointerIdFor], - ) - - useEffect(() => { - const onNodePointerDown = (event: NodeEvent) => { - if (!isXRNodePointer(event)) return - if (useEditor.getState().mode !== 'select') return - if (useInteractionScope.getState().scope.kind !== 'idle') return - - const selectedIds = useViewer.getState().selection.selectedIds - if (!(selectedIds.length === 1 && selectedIds[0] === event.node.id)) return - if (!canDirectMoveNode(event.node)) return - - event.stopPropagation() - useViewer.getState().setInputDragging(true) - createEditorApi().engageMoveDrag(event.node) - } - const onNodeClick = (event: NodeEvent) => { - if (!isXRNodePointer(event)) return - const source = getSpatialPointerId(event.nativeEvent) - if (typeof source === 'object') { - selectReleaseGuard.current.markNodeClick(source as XRInputSource) - } - } - - emitter.on('node:pointerdown', onNodePointerDown) - emitter.on('node:click', onNodeClick) - return () => { - emitter.off('node:pointerdown', onNodePointerDown) - emitter.off('node:click', onNodeClick) - } - }, []) - - useEffect(() => { - if (!session) return - - const rememberXRWallEvent = (event: WallEvent) => { - lastXRWallEvent.current = event - } - const clearXRWallEvent = (event: WallEvent) => { - if (capturedInputSource.current != null) return - lastXRWallEvent.current = null - } - emitter.on('wall:enter', rememberXRWallEvent) - emitter.on('wall:move', rememberXRWallEvent) - emitter.on('wall:leave', clearXRWallEvent) - - const onSelectStart = (event: XRInputSourceEvent) => { - selectReleaseGuard.current.start(event.inputSource) - if (isWandPanelHit(event.frame, event.inputSource)) { - panelInputSources.current.add(xrInputSourceKey(event.inputSource)) - pulseXRInputSource(event.inputSource, 0.1, 20) - return - } - capturedInputSource.current = event.inputSource - pulseXRInputSource(event.inputSource) - if (useEditor.getState().mode === 'terrain-sculpt') { - terrainInputSources.current.add(xrInputSourceKey(event.inputSource)) - startTerrainStroke(event.frame, event.inputSource) - return - } - emitGridEvent('pointerdown', event.frame, event.inputSource, 1) - } - const onSelectEnd = (event: XRInputSourceEvent) => { - const releaseMode = useEditor.getState().mode - const releaseTool = useEditor.getState().tool - const wallOpeningToolActive = - releaseMode === 'build' && (releaseTool === 'door' || releaseTool === 'window') - if (panelInputSources.current.delete(xrInputSourceKey(event.inputSource))) { - selectReleaseGuard.current.cancel(event.inputSource) - return - } - if (releaseMode === 'terrain-sculpt') { - terrainInputSources.current.delete(xrInputSourceKey(event.inputSource)) - finishTerrainStroke(event.inputSource) - selectReleaseGuard.current.cancel(event.inputSource) - capturedInputSource.current = null - return - } - if ( - !sameXRInputSource(capturedInputSource.current, event.inputSource) && - !wallOpeningToolActive - ) { - selectReleaseGuard.current.cancel(event.inputSource) - return - } - - const handledSpatialRelease = spatialPointerInput.release(event.inputSource) - - const pressDrag = useEditor.getState().placementDragMode - const mode = releaseMode - const scope = useInteractionScope.getState().scope - const releaseAction = resolveXRReleaseAction({ - mode, - placementDrag: pressDrag, - scopeKind: scope.kind, - }) - const emptySelectionEvent = - releaseAction === 'defer-empty-selection' - ? createGridEvent(event.frame, event.inputSource, 0, true) - : null - pulseXRInputSource(event.inputSource, 0.08, 18) - emitGridEvent('pointerup', event.frame, event.inputSource, 0) - dispatchWindowPointerEvent('pointerup', event.inputSource) - - if (wallOpeningToolActive && lastXRWallEvent.current) { - replayXRWallOpeningRelease(lastXRWallEvent.current, (suffix, wallEvent) => { - if (suffix === 'move') emitter.emit('wall:move', wallEvent) - else emitter.emit('wall:click', wallEvent) - }) - lastXRWallEvent.current = null - } - - if (handledSpatialRelease && !wallOpeningToolActive) { - selectReleaseGuard.current.cancel(event.inputSource) - } else if (releaseAction === 'finish-placement-drag') { - useViewer.getState().setInputDragging(false) - selectReleaseGuard.current.cancel(event.inputSource) - } else if (releaseAction === 'emit-tool-grid-click') { - emitGridEvent('click', event.frame, event.inputSource, 0) - selectReleaseGuard.current.cancel(event.inputSource) - } else if (releaseAction === 'defer-empty-selection' && emptySelectionEvent) { - selectReleaseGuard.current.deferEmptyRelease(event.inputSource, () => { - if (useEditor.getState().mode !== 'select') return - if (useInteractionScope.getState().scope.kind !== 'idle') return - if (useViewer.getState().inputDragging) return - emitter.emit('grid:click', emptySelectionEvent) - }) - } else { - selectReleaseGuard.current.cancel(event.inputSource) - } - - capturedInputSource.current = null - } - const onSelectCancel = (event: XRInputSourceEvent) => { - if (panelInputSources.current.delete(xrInputSourceKey(event.inputSource))) { - selectReleaseGuard.current.cancel(event.inputSource) - return - } - if (terrainInputSources.current.delete(xrInputSourceKey(event.inputSource))) { - abandonTerrainStroke() - selectReleaseGuard.current.cancel(event.inputSource) - capturedInputSource.current = null - return - } - if (!sameXRInputSource(capturedInputSource.current, event.inputSource)) { - selectReleaseGuard.current.cancel(event.inputSource) - return - } - - const handledSpatialCancel = spatialPointerInput.cancel(event.inputSource) - emitGridEvent('pointerup', event.frame, event.inputSource, 0) - dispatchWindowPointerEvent('pointercancel', event.inputSource) - if (!handledSpatialCancel && useEditor.getState().placementDragMode) { - useViewer.getState().setInputDragging(false) - } - selectReleaseGuard.current.cancel(event.inputSource) - capturedInputSource.current = null - } - - session.addEventListener('selectstart', onSelectStart) - session.addEventListener('selectend', onSelectEnd) - session.addEventListener('selectcancel', onSelectCancel as unknown as EventListener) - return () => { - session.removeEventListener('selectstart', onSelectStart) - session.removeEventListener('selectend', onSelectEnd) - session.removeEventListener('selectcancel', onSelectCancel as unknown as EventListener) - abandonTerrainStroke() - emitter.off('wall:enter', rememberXRWallEvent) - emitter.off('wall:move', rememberXRWallEvent) - emitter.off('wall:leave', clearXRWallEvent) - } - }, [ - abandonTerrainStroke, - createGridEvent, - dispatchWindowPointerEvent, - emitGridEvent, - finishTerrainStroke, - isWandPanelHit, - session, - startTerrainStroke, - ]) - - useFrame((_, __, frame) => { - if (!(frame && session)) return - const inputSources = Array.from(session.inputSources) - if (shouldReleaseCapturedXRInput(inputSources, capturedInputSource.current)) { - spatialPointerInput.cancel(capturedInputSource.current!) - dispatchWindowPointerEvent('pointercancel', capturedInputSource.current!) - if (useEditor.getState().placementDragMode) { - useViewer.getState().setInputDragging(false) - } - selectReleaseGuard.current.cancel(capturedInputSource.current!) - capturedInputSource.current = null - } - const source = selectPrimaryXRInputSource(inputSources, capturedInputSource.current) - const panelHit = source ? isWandPanelHit(frame, source) : false - if (source && useEditor.getState().mode === 'terrain-sculpt' && !panelHit) { - const site = activeSite() - if (site) { - const field = terrainStroke.current?.stroke.snapshot ?? sculptFieldForSite(site) - const point = terrainPoint(frame, source, field, site) - const radius = Math.max(useEditor.getState().terrainBrush.radius, minBrushRadius(field)) - terrainFocus.current = point ? { radius, siteId: site.id, x: point[0], z: point[1] } : null - } - } else if (useEditor.getState().mode !== 'terrain-sculpt' || panelHit) { - terrainFocus.current = null - } - if ( - source && - shouldRouteXRMove(source, capturedInputSource.current, panelHit) && - (capturedInputSource.current == null || - sameXRInputSource(capturedInputSource.current, source)) - ) { - if (useEditor.getState().mode === 'terrain-sculpt') { - if (capturedInputSource.current === source) applyTerrainDab(frame, source) - } else { - emitGridEvent('move', frame, source, capturedInputSource.current ? 1 : 0) - const editor = useEditor.getState() - if (editor.mode === 'build' && (editor.tool === 'door' || editor.tool === 'window')) { - emitWallOpeningHover(frame, source) - } else { - lastSyntheticWallEvent.current = null - } - spatialPointerInput.move(source, raycaster.current.ray) - } - } - - const nextCancelPressed = isXRCancelPressed(inputSources) - if (didXRButtonPressStart(cancelPressed.current, nextCancelPressed)) { - abandonTerrainStroke() - cancelActiveTool() - const rightController = inputSources.find( - (inputSource) => inputSource.handedness === 'right' && inputSource.gamepad != null, - ) - if (rightController) pulseXRInputSource(rightController, 0.25, 35) - useViewer.getState().setInputDragging(false) - } - cancelPressed.current = nextCancelPressed - }) - - return -} diff --git a/apps/editor/components/xr/xr-emulator-test-harness.tsx b/apps/editor/components/xr/xr-emulator-test-harness.tsx deleted file mode 100644 index 4f01cde4e6..0000000000 --- a/apps/editor/components/xr/xr-emulator-test-harness.tsx +++ /dev/null @@ -1,779 +0,0 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - emitter, - type GridEvent, - type NodeEvent, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { getHistoryCommandState, useEditor, useInteractionScope } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useThree } from '@react-three/fiber' -import { useXR } from '@react-three/xr' -import { useEffect } from 'react' -import { Box3, Object3D, Quaternion, Raycaster, Vector3 } from 'three' -import { getEmulatedXRDevice } from '@/lib/xr/emulator' -import { resolveEmulatedInputPose } from '@/lib/xr/emulator-ray' -import { useXRWandPanelSettings } from '@/lib/xr/wand-panel-settings' - -type InputKind = 'controller' | 'hand' - -const XR_INPUT_EVENT_TIMEOUT_MS = 150 -const XR_FRAME_TIMEOUT_MS = 100 - -export type XREmulatorTestHarness = { - aimAt: (name: string, inputKind?: InputKind) => Promise - aimAtNode: (nodeId: string, inputKind?: InputKind, distance?: number) => Promise - click: (name: string, inputKind?: InputKind) => Promise - clickLevelPoint: (point: [number, number], inputKind?: InputKind) => Promise - clickNode: (nodeId: string, inputKind?: InputKind) => Promise - clickNodeSurface: (nodeId: string, inputKind?: InputKind) => Promise - drag: (names: string[], inputKind?: InputKind) => Promise - dragNodeTo: ( - nodeId: string, - worldPoint: [number, number, number], - inputKind?: InputKind, - ) => Promise - listSceneNodes: () => { id: string; parentId: string | null; type: string }[] - listSpatialTargets: () => string[] - panGodView: (delta: [number, number, number]) => Promise - placeToolOnGrid: ( - toolTarget: string, - nodeType: string, - points: [number, number][], - inputKind?: InputKind, - ) => Promise<{ - activated: boolean - cancelled: boolean - createdNodeIds: string[] - deliveredPoints: number - }> - placeToolOnNode: ( - toolTarget: string, - hostNodeId: string, - nodeType: string, - inputKind?: InputKind, - ) => Promise<{ - activated: boolean - attachedToHost: boolean - cancelled: boolean - createdNodeIds: string[] - deliveredHostClick: boolean - }> - probe: (name: string, inputKind?: InputKind) => Promise> - probeNode: ( - nodeId: string, - inputKind?: InputKind, - distance?: number, - ) => Promise> - readNode: (nodeId: string) => AnyNode | undefined - sculptLevelPoints: (points: [number, number][], inputKind?: InputKind) => Promise - snapshot: () => { - activePaintMaterial: string | null - hoveredTarget?: string - history: { canRedo: boolean; canUndo: boolean; mode: string; status: string } - godViewTransform: { position: number[]; rotationY: number; scale: number[] } | null - lastGridEvent?: string - lastNodeEvent?: string - lastPointerEvent?: string - levelId: string | null - mode: string - nodeCounts: Record - paintEraser: boolean - paintHover: { nodeNoun: string; scopes: string[]; slotLabel: string } | null - paintScope: string - terrainBrush: { falloff: number; radius: number; shape: string; strength: number } - terrainSampling: boolean - terrainVerb: string - wandPanelScale: number - wallSnappingMode: string - scope: string - selectedIds: string[] - siteHasTerrain: boolean - tool: string | null - toolDefaults: Record - } - version: 1 -} - -declare global { - var __pascalXRLastGridEvent: string | undefined - var __pascalXRLastNodeEvent: string | undefined - var __pascalXRTestHarness: XREmulatorTestHarness | undefined -} - -export function XREmulatorTestHarnessBridge() { - const scene = useThree((state) => state.scene) - const camera = useThree((state) => state.camera) - const origin = useXR((state) => state.origin) - const session = useXR((state) => state.session) - - useEffect(() => { - if (!(origin && session && process.env.NODE_ENV === 'development')) return - - const recordNodeClick = (event: NodeEvent) => { - globalThis.__pascalXRLastNodeEvent = `click:${event.node.id}` - } - const recordNodeDown = (event: NodeEvent) => { - globalThis.__pascalXRLastNodeEvent = `down:${event.node.id}` - } - const recordGridClick = (event: GridEvent) => { - globalThis.__pascalXRLastGridEvent = `click:${event.localPosition.join(',')}` - } - emitter.on('node:click', recordNodeClick) - emitter.on('node:pointerdown', recordNodeDown) - emitter.on('grid:click', recordGridClick) - - const waitForXRFrames = (count = 1) => - new Promise((resolve) => { - const timeout = window.setTimeout(resolve, XR_FRAME_TIMEOUT_MS) - const next = (remaining: number) => { - session.requestAnimationFrame(() => { - if (remaining === 1) { - window.clearTimeout(timeout) - resolve() - } else next(remaining - 1) - }) - } - next(count) - }) - - const prepareInput = async (inputKind: InputKind) => { - const device = getEmulatedXRDevice() - if (!device) return false - const deviceId = `${inputKind}-right` - const inputModeChanged = device.primaryInputMode !== inputKind - if (inputModeChanged) { - await device.remote.dispatch('set_input_mode', { mode: inputKind }) - } - await device.remote.dispatch('set_connected', { - connected: true, - device: `${inputKind}-left`, - }) - await device.remote.dispatch('set_connected', { connected: true, device: deviceId }) - const leftPosition = - inputKind === 'controller' ? { x: -0.25, y: 1.5, z: -0.4 } : { x: -0.15, y: 1.3, z: -0.4 } - await device.remote.dispatch('set_transform', { - device: `${inputKind}-left`, - orientation: { w: 1, x: 0, y: 0, z: 0 }, - position: leftPosition, - }) - await waitForXRFrames(inputModeChanged ? 2 : 1) - return true - } - - const setInputPose = async (target: Object3D, inputKind: InputKind, distance = 0.5) => { - const device = getEmulatedXRDevice() - if (!device) return false - const pose = resolveEmulatedInputPose(target, origin, distance) - const deviceId = `${inputKind}-right` - await device.remote.dispatch('set_transform', { - device: deviceId, - orientation: { - w: pose.quaternion[3], - x: pose.quaternion[0], - y: pose.quaternion[1], - z: pose.quaternion[2], - }, - position: { x: pose.position[0], y: pose.position[1], z: pose.position[2] }, - }) - await waitForXRFrames() - return true - } - - const findTarget = (name: string) => { - const matches: Object3D[] = [] - scene.traverseVisible((object) => { - if (object.name === name) matches.push(object) - }) - return matches[0] - } - - const waitForTarget = async (name: string) => { - for (let attempt = 0; attempt < 20; attempt += 1) { - const target = findTarget(name) - if (target) return target - await waitForXRFrames() - } - return undefined - } - - const aimAt = async (name: string, inputKind: InputKind = 'controller') => { - globalThis.__pascalXRHoveredTarget = undefined - if (!(await prepareInput(inputKind))) return false - const target = await waitForTarget(name) - if (!(target && (await setInputPose(target, inputKind)))) return false - if (inputKind === 'hand') return true - for (let attempt = 0; attempt < 5; attempt += 1) { - if (globalThis.__pascalXRHoveredTarget === name) return true - await waitForXRFrames() - } - return findTarget(name) === target - } - - const aimAtNode = async ( - nodeId: string, - inputKind: InputKind = 'controller', - distance = 1.25, - ) => { - if (!(await prepareInput(inputKind))) return false - const device = getEmulatedXRDevice() - if (!device) return false - await device.remote.dispatch('set_transform', { - device: `${inputKind}-left`, - orientation: { w: 1, x: 0, y: 0, z: 0 }, - position: { x: -3, y: 1.5, z: 0 }, - }) - await waitForXRFrames() - const registered = sceneRegistry.nodes.get(nodeId) - if (!registered) return false - registered.updateWorldMatrix(true, true) - const bounds = new Box3().setFromObject(registered) - const target = new Object3D() - let targetDistance = distance - if (bounds.isEmpty()) { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - const vertices = ( - node as { topology?: { vertices?: { position?: number[] }[] } } | undefined - )?.topology?.vertices - const positions = vertices - ?.map((vertex) => vertex.position) - .filter( - (position): position is [number, number, number] => - position?.length === 3 && position.every(Number.isFinite), - ) - if (positions && positions.length > 0) { - const localBounds = new Box3().setFromPoints( - positions.map((position) => new Vector3().fromArray(position)), - ) - localBounds.getCenter(target.position) - registered.localToWorld(target.position) - const worldScale = registered.getWorldScale(new Vector3()) - targetDistance = Math.max( - targetDistance, - localBounds.getSize(new Vector3()).multiply(worldScale).length() / 2 + 0.25, - ) - } else { - registered.getWorldPosition(target.position) - } - } else { - bounds.getCenter(target.position) - targetDistance = Math.max(targetDistance, bounds.getSize(new Vector3()).length() / 2 + 0.25) - } - const normal = camera.getWorldPosition(new Vector3()).sub(target.position).normalize() - target.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), normal) - target.updateMatrixWorld(true) - const positioned = await setInputPose(target, inputKind, targetDistance) - if (positioned) await waitForXRFrames(2) - return positioned - } - - const setSelectValue = async (value: number, inputKind: InputKind) => { - const device = getEmulatedXRDevice() - if (!device) return false - await device.remote.dispatch('set_select_value', { - device: `${inputKind}-right`, - value, - }) - return true - } - - const waitForInputEvent = (inputKind: InputKind, eventType: 'selectend' | 'selectstart') => - new Promise((resolve) => { - const timeout = window.setTimeout(() => { - session.removeEventListener(eventType, listener) - resolve(false) - }, XR_INPUT_EVENT_TIMEOUT_MS) - const listener = (event: XRInputSourceEvent) => { - const matchesKind = - inputKind === 'hand' ? event.inputSource.hand != null : !event.inputSource.hand - if (event.inputSource.handedness !== 'right' || !matchesKind) return - window.clearTimeout(timeout) - session.removeEventListener(eventType, listener) - resolve(true) - } - session.addEventListener(eventType, listener) - }) - - const setSelectValueAndWait = async ( - value: 0 | 1, - inputKind: InputKind, - eventType: 'selectend' | 'selectstart', - ) => { - const eventReceived = waitForInputEvent(inputKind, eventType) - if (!(await setSelectValue(value, inputKind))) return false - await waitForXRFrames(2) - await eventReceived - return eventReceived - } - - const click = async (name: string, inputKind: InputKind = 'controller') => { - if (!(await aimAt(name, inputKind))) return false - for (let attempt = 0; attempt < 3; attempt += 1) { - if (attempt > 0 && !(await aimAt(name, inputKind))) return false - globalThis.__pascalXRLastPointerEvent = undefined - await setSelectValueAndWait(1, inputKind, 'selectstart') - await setSelectValueAndWait(0, inputKind, 'selectend') - if (globalThis.__pascalXRLastPointerEvent === `click:${name}`) return true - } - return false - } - - const panGodView = async (delta: [number, number, number]) => { - if (!(await prepareInput('controller'))) return false - const device = getEmulatedXRDevice() - const root = findTarget('xr-player-scene-root') - if (!(device && root)) return false - const deviceId = 'controller-right' - const transform = (await device.remote.dispatch('get_transform', { device: deviceId })) as { - orientation: { w: number; x: number; y: number; z: number } - position: { x: number; y: number; z: number } - } - await device.remote.dispatch('set_gamepad_state', { - buttons: [{ index: 1, value: 1 }], - device: deviceId, - }) - await waitForXRFrames(2) - await device.remote.dispatch('set_transform', { - device: deviceId, - orientation: transform.orientation, - position: { - x: transform.position.x + delta[0], - y: transform.position.y + delta[1], - z: transform.position.z + delta[2], - }, - }) - await waitForXRFrames(2) - await device.remote.dispatch('set_gamepad_state', { - buttons: [{ index: 1, value: 0 }], - device: deviceId, - }) - await waitForXRFrames() - return root.position.lengthSq() > 0.000_001 - } - - const clickLevelPoint = async ( - point: [number, number], - inputKind: InputKind = 'controller', - ) => { - if (!(await prepareInput(inputKind))) return false - const levelId = useViewer.getState().selection.levelId - const levelNode = levelId ? useScene.getState().nodes[levelId] : undefined - const levelObject = levelId ? sceneRegistry.nodes.get(levelId) : undefined - const device = getEmulatedXRDevice() - if (levelNode?.type !== 'level' || !device) return false - await device.remote.dispatch('set_transform', { - device: `${inputKind}-left`, - orientation: { w: 1, x: 0, y: 0, z: 0 }, - position: { x: -3, y: 1.5, z: 0 }, - }) - const buildingObject = levelNode.parentId - ? sceneRegistry.nodes.get(levelNode.parentId) - : undefined - levelObject?.updateWorldMatrix(true, false) - buildingObject?.updateWorldMatrix(true, false) - const target = new Object3D() - const localPoint = new Vector3(point[0], levelNode.baseElevation, point[1]) - target.position.copy( - levelObject - ? levelObject.localToWorld(new Vector3(point[0], 0, point[1])) - : buildingObject - ? buildingObject.localToWorld(localPoint) - : localPoint, - ) - const normal = new Vector3(0, 1, 0) - if (levelObject) normal.transformDirection(levelObject.matrixWorld) - else if (buildingObject) normal.transformDirection(buildingObject.matrixWorld) - target.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), normal) - target.updateMatrixWorld(true) - if (!(await setInputPose(target, inputKind, 1.25))) return false - await waitForXRFrames(2) - globalThis.__pascalXRLastGridEvent = undefined - await setSelectValueAndWait(1, inputKind, 'selectstart') - await setSelectValueAndWait(0, inputKind, 'selectend') - await waitForXRFrames(2) - const lastGridEvent = globalThis.__pascalXRLastGridEvent as string | undefined - return lastGridEvent?.startsWith('click:') === true - } - - const clickNode = async (nodeId: string, inputKind: InputKind = 'controller') => { - if (!(await aimAtNode(nodeId, inputKind))) return false - for (let attempt = 0; attempt < 3; attempt += 1) { - if (attempt > 0 && !(await aimAtNode(nodeId, inputKind))) return false - await setSelectValueAndWait(1, inputKind, 'selectstart') - await setSelectValueAndWait(0, inputKind, 'selectend') - if (useViewer.getState().selection.selectedIds.includes(nodeId)) return true - } - return false - } - - const sculptLevelPoints = async ( - points: [number, number][], - inputKind: InputKind = 'controller', - ) => { - const first = points[0] - if (!(first && (await prepareInput(inputKind)))) return false - const levelId = useViewer.getState().selection.levelId - const levelNode = levelId ? useScene.getState().nodes[levelId] : undefined - const device = getEmulatedXRDevice() - if (levelNode?.type !== 'level' || !device) return false - const buildingObject = levelNode.parentId - ? sceneRegistry.nodes.get(levelNode.parentId) - : undefined - buildingObject?.updateWorldMatrix(true, false) - const setPoint = async (point: [number, number]) => { - const target = new Object3D() - const localPoint = new Vector3(point[0], levelNode.baseElevation, point[1]) - target.position.copy(buildingObject ? buildingObject.localToWorld(localPoint) : localPoint) - const normal = new Vector3(0, 1, 0) - if (buildingObject) normal.transformDirection(buildingObject.matrixWorld) - target.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), normal) - target.updateMatrixWorld(true) - return setInputPose(target, inputKind, 1.25) - } - const siteId = useScene.getState().rootNodeIds[0] - const beforeSite = siteId ? useScene.getState().nodes[siteId] : undefined - const before = beforeSite?.type === 'site' ? beforeSite.terrain : undefined - if (!(await setPoint(first))) return false - if (!(await setSelectValueAndWait(1, inputKind, 'selectstart'))) return false - for (const point of points.slice(1)) { - if (!(await setPoint(point))) { - await setSelectValue(0, inputKind) - return false - } - await waitForXRFrames(2) - } - if (!(await setSelectValueAndWait(0, inputKind, 'selectend'))) return false - await waitForXRFrames(2) - const afterSite = siteId ? useScene.getState().nodes[siteId] : undefined - const after = afterSite?.type === 'site' ? afterSite.terrain : undefined - return JSON.stringify(after) !== JSON.stringify(before) - } - - const clickNodeSurface = async (nodeId: string, inputKind: InputKind = 'controller') => { - const aimAtNodeFace = async () => { - if (!(await prepareInput(inputKind))) return false - const registered = sceneRegistry.nodes.get(nodeId) - const device = getEmulatedXRDevice() - if (!(registered && device)) return false - await device.remote.dispatch('set_transform', { - device: `${inputKind}-left`, - orientation: { w: 1, x: 0, y: 0, z: 0 }, - position: { x: -3, y: 1.5, z: 0 }, - }) - registered.updateWorldMatrix(true, true) - const target = new Object3D() - const bounds = new Box3().setFromObject(registered) - if (bounds.isEmpty()) registered.getWorldPosition(target.position) - else bounds.getCenter(target.position) - registered.getWorldQuaternion(target.quaternion) - target.updateMatrixWorld(true) - const positioned = await setInputPose(target, inputKind, 0.2) - if (positioned) await waitForXRFrames(2) - return positioned - } - - if (!(await aimAtNodeFace())) return false - for (let attempt = 0; attempt < 3; attempt += 1) { - if (attempt > 0 && !(await aimAtNodeFace())) return false - globalThis.__pascalXRLastNodeEvent = undefined - await setSelectValueAndWait(1, inputKind, 'selectstart') - await setSelectValueAndWait(0, inputKind, 'selectend') - await waitForXRFrames(2) - if (globalThis.__pascalXRLastNodeEvent === `click:${nodeId}`) return true - } - return false - } - - const drag = async (names: string[], inputKind: InputKind = 'controller') => { - const first = names[0] - if (!(first && (await aimAt(first, inputKind)))) return false - await setSelectValueAndWait(1, inputKind, 'selectstart') - await waitForXRFrames(2) - for (const name of names.slice(1)) { - if (!(await aimAt(name, inputKind))) { - await setSelectValue(0, inputKind) - return false - } - } - await setSelectValueAndWait(0, inputKind, 'selectend') - await waitForXRFrames() - return globalThis.__pascalXRLastPointerEvent === `click:${names.at(-1)}` - } - - const dragNodeTo = async ( - nodeId: string, - worldPoint: [number, number, number], - inputKind: InputKind = 'controller', - ) => { - const registered = sceneRegistry.nodes.get(nodeId) - if (!registered) return false - if (!useViewer.getState().selection.selectedIds.includes(nodeId)) { - if (!(await clickNode(nodeId, inputKind))) return false - } - const initialNodeState = JSON.stringify(useScene.getState().nodes[nodeId as AnyNodeId]) - if (!(await aimAtNode(nodeId, inputKind))) return false - await setSelectValueAndWait(1, inputKind, 'selectstart') - await waitForXRFrames(2) - const floorTarget = new Object3D() - floorTarget.position.fromArray(worldPoint) - floorTarget.rotation.x = -Math.PI / 2 - floorTarget.updateMatrixWorld(true) - await setInputPose(floorTarget, inputKind, 1.25) - await waitForXRFrames(2) - await setSelectValueAndWait(0, inputKind, 'selectend') - await waitForXRFrames() - return JSON.stringify(useScene.getState().nodes[nodeId as AnyNodeId]) !== initialNodeState - } - - const probe = async (name: string, inputKind: InputKind = 'controller') => { - const device = getEmulatedXRDevice() - if (!device) return { error: 'missing device' } - await aimAt(name, inputKind) - const target = findTarget(name) - if (!target) return { error: 'missing target' } - const transform = (await device.remote.dispatch('get_transform', { - device: `${inputKind}-right`, - })) as { - orientation: { w: number; x: number; y: number; z: number } - position: { x: number; y: number; z: number } - } - const rayOrigin = new Vector3( - transform.position.x, - transform.position.y, - transform.position.z, - ).applyMatrix4(origin.matrixWorld) - const rayDirection = new Vector3(0, 0, -1) - .applyQuaternion( - new Quaternion( - transform.orientation.x, - transform.orientation.y, - transform.orientation.z, - transform.orientation.w, - ), - ) - .transformDirection(origin.matrixWorld) - const raycaster = new Raycaster(rayOrigin, rayDirection) - raycaster.layers.enableAll() - return { - rayDirection: rayDirection.toArray(), - rayOrigin: rayOrigin.toArray(), - targetPosition: target.getWorldPosition(new Vector3()).toArray(), - firstHits: raycaster - .intersectObjects(scene.children, true) - .slice(0, 8) - .map((hit) => ({ distance: hit.distance, name: hit.object.name })), - targetHits: raycaster.intersectObject(target, false).length, - } - } - - const probeNode = async ( - nodeId: string, - inputKind: InputKind = 'controller', - distance = 1.25, - ) => { - const registered = sceneRegistry.nodes.get(nodeId) - const device = getEmulatedXRDevice() - if (!(registered && device && (await aimAtNode(nodeId, inputKind, distance)))) { - return { error: 'missing node or device' } - } - const transform = (await device.remote.dispatch('get_transform', { - device: `${inputKind}-right`, - })) as { - orientation: { w: number; x: number; y: number; z: number } - position: { x: number; y: number; z: number } - } - const rayOrigin = new Vector3( - transform.position.x, - transform.position.y, - transform.position.z, - ).applyMatrix4(origin.matrixWorld) - const rayDirection = new Vector3(0, 0, -1) - .applyQuaternion( - new Quaternion( - transform.orientation.x, - transform.orientation.y, - transform.orientation.z, - transform.orientation.w, - ), - ) - .transformDirection(origin.matrixWorld) - const raycaster = new Raycaster(rayOrigin, rayDirection) - raycaster.layers.enableAll() - registered.updateWorldMatrix(true, true) - const registeredBounds = new Box3().setFromObject(registered) - const describeHit = (object: Object3D) => { - const path: { childTargets: string[]; eventCount: number; name: string; type: string }[] = - [] - let current: Object3D | null = object - while (current && path.length < 8) { - path.push({ - childTargets: current.children - .filter( - (child) => - ((child as Object3D & { __r3f?: { eventCount?: number } }).__r3f?.eventCount ?? - 0) > 0, - ) - .map((child) => child.name || child.type), - eventCount: - (current as Object3D & { __r3f?: { eventCount?: number } }).__r3f?.eventCount ?? 0, - name: current.name, - type: current.type, - }) - current = current.parent - } - return path - } - return { - bounds: registeredBounds.isEmpty() - ? null - : { - max: registeredBounds.max.toArray(), - min: registeredBounds.min.toArray(), - }, - childCount: registered.children.length, - firstHits: raycaster - .intersectObjects(scene.children, true) - .slice(0, 8) - .map((hit) => ({ - distance: hit.distance, - name: hit.object.name, - path: describeHit(hit.object), - })), - nodeHits: raycaster.intersectObject(registered, true).length, - rayDirection: rayDirection.toArray(), - rayOrigin: rayOrigin.toArray(), - registeredPosition: registered.getWorldPosition(new Vector3()).toArray(), - } - } - - const harness: XREmulatorTestHarness = { - aimAt, - aimAtNode, - click, - clickLevelPoint, - clickNode, - clickNodeSurface, - drag, - dragNodeTo, - listSceneNodes: () => - Object.values(useScene.getState().nodes) - .filter((node): node is NonNullable => node != null) - .map((node) => ({ id: node.id, parentId: node.parentId, type: node.type })) - .sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id)), - listSpatialTargets: () => { - const names = new Set() - scene.traverseVisible((object) => { - if (object.name.startsWith('xr-') && 'raycast' in object) names.add(object.name) - }) - return [...names].sort() - }, - placeToolOnGrid: async (toolTarget, nodeType, points, inputKind = 'controller') => { - const before = new Set( - Object.values(useScene.getState().nodes) - .filter((node) => node?.type === nodeType) - .map((node) => node!.id), - ) - const activated = await click(toolTarget, inputKind) - let deliveredPoints = 0 - if (activated) { - await waitForXRFrames(2) - for (const point of points) { - if (!(await clickLevelPoint(point, inputKind))) break - deliveredPoints += 1 - } - } - const createdNodeIds = Object.values(useScene.getState().nodes) - .filter((node): node is AnyNode => node?.type === nodeType && !before.has(node.id)) - .map((node) => node.id) - const cancelled = await click('xr-build-tool-select', inputKind) - return { activated, cancelled, createdNodeIds, deliveredPoints } - }, - placeToolOnNode: async (toolTarget, hostNodeId, nodeType, inputKind = 'controller') => { - const before = new Set( - Object.values(useScene.getState().nodes) - .filter((node) => node?.type === nodeType) - .map((node) => node!.id), - ) - const activated = await click(toolTarget, inputKind) - if (activated) await waitForXRFrames(2) - const deliveredHostClick = activated && (await clickNodeSurface(hostNodeId, inputKind)) - const createdNodes = Object.values(useScene.getState().nodes).filter( - (node): node is AnyNode => node?.type === nodeType && !before.has(node.id), - ) - const attachedToHost = - createdNodes.length > 0 && createdNodes.every((node) => node.parentId === hostNodeId) - const alreadySelect = - useEditor.getState().mode === 'select' && useEditor.getState().tool === null - const cancelled = alreadySelect || (await click('xr-build-tool-select', inputKind)) - return { - activated, - attachedToHost, - cancelled, - createdNodeIds: createdNodes.map((node) => node.id), - deliveredHostClick, - } - }, - panGodView, - probe, - probeNode, - readNode: (nodeId) => useScene.getState().nodes[nodeId as AnyNode['id']], - sculptLevelPoints, - snapshot: () => { - const godViewRoot = findTarget('xr-player-scene-root') - const nodeCounts: Record = {} - for (const node of Object.values(useScene.getState().nodes)) { - if (node) nodeCounts[node.type] = (nodeCounts[node.type] ?? 0) + 1 - } - return { - activePaintMaterial: useEditor.getState().activePaintMaterial?.materialPreset ?? null, - godViewTransform: godViewRoot - ? { - position: godViewRoot.position.toArray(), - rotationY: godViewRoot.rotation.y, - scale: godViewRoot.scale.toArray(), - } - : null, - history: getHistoryCommandState(), - hoveredTarget: globalThis.__pascalXRHoveredTarget, - lastGridEvent: globalThis.__pascalXRLastGridEvent, - lastNodeEvent: globalThis.__pascalXRLastNodeEvent, - lastPointerEvent: globalThis.__pascalXRLastPointerEvent, - levelId: useViewer.getState().selection.levelId, - mode: useEditor.getState().mode, - nodeCounts, - paintEraser: useEditor.getState().paintEraser, - paintHover: useEditor.getState().paintHover, - paintScope: useEditor.getState().paintScope, - terrainBrush: useEditor.getState().terrainBrush, - terrainSampling: useEditor.getState().terrainSampling, - terrainVerb: useEditor.getState().terrainVerb, - wandPanelScale: useXRWandPanelSettings.getState().panelScale, - wallSnappingMode: useEditor.getState().snappingModeByContext.wall, - scope: useInteractionScope.getState().scope.kind, - selectedIds: useViewer.getState().selection.selectedIds, - siteHasTerrain: Object.values(useScene.getState().nodes).some( - (node) => node?.type === 'site' && node.terrain !== undefined, - ), - tool: useEditor.getState().tool, - toolDefaults: useEditor.getState().toolDefaults, - } - }, - version: 1, - } - globalThis.__pascalXRTestHarness = harness - return () => { - emitter.off('node:click', recordNodeClick) - emitter.off('node:pointerdown', recordNodeDown) - emitter.off('grid:click', recordGridClick) - if (globalThis.__pascalXRTestHarness === harness) { - globalThis.__pascalXRTestHarness = undefined - } - } - }, [camera, origin, scene, session]) - - return null -} diff --git a/apps/editor/components/xr/xr-preview-environment.tsx b/apps/editor/components/xr/xr-preview-environment.tsx deleted file mode 100644 index 4cfcbf4838..0000000000 --- a/apps/editor/components/xr/xr-preview-environment.tsx +++ /dev/null @@ -1,329 +0,0 @@ -'use client' - -import { initSpaceDetectionSync, SiteNode, useScene } from '@pascal-app/core' -import { - applySceneGraphToEditor, - Grid, - NodeArrowHandles, - type SceneGraph, - SelectionManager, - selectDefaultBuildingAndLevel, - ToolManager, - useEditor, - WallMoveSideHandles, -} from '@pascal-app/editor' -import { - requestGodScaleReset, - toggleXRPlayerMode, - useViewer, - useXRPlayerMode, - Viewer, - XR_PLAYER_MODES, -} from '@pascal-app/viewer' -import { Glasses, LoaderCircle, Orbit, PersonStanding, RotateCcw, X } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { mountEmulatorControls } from '@/lib/xr/emulator' -import { XR_PREVIEW_SCENE_KEY } from '@/lib/xr/preview-window' -import { XRWandInputOverlay } from './wand-panel' -import { XREditorInputBridge } from './xr-editor-input-bridge' -import { XREmulatorTestHarnessBridge } from './xr-emulator-test-harness' -import { XRRenderErrorBoundary } from './xr-render-error-boundary' -import { requestEditorVRSession, useEditorXRRuntime, xrConfigForRuntime } from './xr-runtime' - -const LOCAL_SCENE_KEY = 'pascal-editor-scene' - -function endXRSession(session?: XRSession) { - if (!session) return - void session.end().catch(() => undefined) -} - -type PreviewScene = { - graph: SceneGraph - name: string -} - -function ensureXRPreviewSite(graph: SceneGraph): SceneGraph { - const rootNodeIds = graph.rootNodeIds ?? [] - const sourceNodes = graph.nodes as Record - const existingSite = rootNodeIds.some((id) => sourceNodes[id]?.type === 'site') - if (existingSite) return graph - - const buildingIds = Object.values(sourceNodes) - .filter((node) => node?.type === 'building') - .map((node) => node.id) - if (buildingIds.length === 0) return graph - - const site = SiteNode.parse({ - id: 'site_xr_preview' as never, - type: 'site', - name: 'XR Preview Site', - polygon: { - type: 'polygon', - points: [ - [-100, -100], - [100, -100], - [100, 100], - [-100, 100], - ], - }, - children: buildingIds, - }) - const nodes = Object.fromEntries( - Object.entries(sourceNodes).map(([id, node]) => - node?.type === 'building' ? [id, { ...node, parentId: site.id }] : [id, node], - ), - ) - return { - ...graph, - nodes: { ...nodes, [site.id]: site }, - rootNodeIds: [site.id], - } -} - -function XREditorScene() { - const gridSnapStep = useEditor((state) => state.gridSnapStep) - - return ( - <> - - - - - - - - - ) -} - -export function XRPreviewEnvironment({ - liveSnapshot = false, - sceneId, -}: { - liveSnapshot?: boolean - sceneId?: string -}) { - const runtime = useEditorXRRuntime(true) - const [scene, setScene] = useState() - const [session, setSession] = useState() - const [error, setError] = useState(null) - const [editorReady, setEditorReady] = useState(false) - const [inputSummary, setInputSummary] = useState('No tracked inputs') - const [enteringVR, setEnteringVR] = useState(false) - const sessionRequest = useRef | null>(null) - const playerMode = useXRPlayerMode((state) => state.mode) - const selectedIds = useViewer((state) => state.selection.selectedIds) - - useEffect(() => { - const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor) - return () => unsubscribeSpaceDetection() - }, []) - - useEffect(() => { - let cancelled = false - void Promise.resolve(useEditor.persist.rehydrate()).then(() => { - if (!cancelled) setEditorReady(true) - }) - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - let cancelled = false - - if (liveSnapshot || !sceneId) { - try { - const storageKey = liveSnapshot ? XR_PREVIEW_SCENE_KEY : LOCAL_SCENE_KEY - const graph = JSON.parse(localStorage.getItem(storageKey) ?? 'null') as SceneGraph | null - setScene( - graph ? { graph, name: liveSnapshot ? 'Current editor scene' : 'Local scene' } : null, - ) - } catch { - setScene(null) - } - return - } - - fetch(`/api/scenes/${encodeURIComponent(sceneId)}`, { cache: 'no-store' }) - .then(async (response) => { - if (!response.ok) throw new Error(`Could not load scene (${response.status})`) - return (await response.json()) as PreviewScene - }) - .then((nextScene) => { - if (!cancelled) setScene(nextScene) - }) - .catch((loadError: unknown) => { - if (cancelled) return - setError(loadError instanceof Error ? loadError.message : 'Could not load scene') - setScene(null) - }) - - return () => { - cancelled = true - } - }, [liveSnapshot, sceneId]) - - useEffect(() => { - if (!(editorReady && scene)) return - applySceneGraphToEditor(ensureXRPreviewSite(scene.graph)) - selectDefaultBuildingAndLevel() - useEditor.setState({ mode: 'select', tool: null }) - return () => applySceneGraphToEditor(null) - }, [editorReady, scene]) - - useEffect(() => { - if (runtime.status !== 'ready') return - - const updateInputSummary = () => { - const state = runtime.store.getState() - const inputs = state.inputSourceStates - setInputSummary( - inputs.length === 0 - ? state.session - ? `Session connected · ${state.session.inputSources.length} source(s) · no tracked inputs` - : 'XR store is waiting for the session' - : inputs.map((input) => `${input.inputSource.handedness} ${input.type}`).join(' · '), - ) - } - updateInputSummary() - return runtime.store.subscribe(updateInputSummary) - }, [runtime]) - - useEffect(() => { - if (!(session && runtime.status === 'ready' && runtime.source === 'emulated')) return - return mountEmulatorControls() - }, [runtime, session]) - - useEffect(() => { - if (!session) return - useEditor.setState({ mode: 'select', tool: null }) - }, [session]) - - const enterVR = useCallback(async () => { - if (runtime.status !== 'ready' || session || sessionRequest.current) return - setError(null) - setEnteringVR(true) - - const request = (async () => { - try { - const nextSession = await requestEditorVRSession(runtime.store) - nextSession.addEventListener('end', () => setSession(undefined), { once: true }) - setSession(nextSession) - } catch (sessionError) { - setError(sessionError instanceof Error ? sessionError.message : 'Could not enter VR') - } finally { - setEnteringVR(false) - sessionRequest.current = null - } - })() - - sessionRequest.current = request - await request - }, [runtime, session]) - - const xr = session - ? { ...xrConfigForRuntime(runtime, session)!, inputSourceOverlay: XRWandInputOverlay } - : undefined - - if (xr && scene) { - return ( -
- endXRSession(session)}> - - - - -
- {playerMode === XR_PLAYER_MODES.GOD ? 'God mode' : 'Human mode'} · {inputSummary} -
-
- - {playerMode === XR_PLAYER_MODES.GOD && ( - - )} - -
-
- ) - } - - const preparing = - !editorReady || runtime.status === 'idle' || runtime.status === 'loading' || scene === undefined - const unavailable = - runtime.status === 'unsupported' || runtime.status === 'error' || scene === null - - return ( -
-
- -

WebXR test environment

-

- {scene?.name ?? 'Preparing the scene'} opens here independently from the editor. Start the - immersive session when the runtime is ready. -

- {error &&

{error}

} - {runtime.status === 'error' && ( -

{runtime.message}

- )} - {scene === null && !error && ( -

No local scene is available to preview.

- )} -
- - -
-
-
- ) -} diff --git a/apps/editor/components/xr/xr-render-error-boundary.tsx b/apps/editor/components/xr/xr-render-error-boundary.tsx deleted file mode 100644 index 212d3f735a..0000000000 --- a/apps/editor/components/xr/xr-render-error-boundary.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import type { ErrorInfo, ReactNode } from 'react' -import { Component } from 'react' - -type Props = { - children: ReactNode - onExit: () => void -} - -type State = { - error: Error | null -} - -export class XRRenderErrorBoundary extends Component { - state: State = { error: null } - - static getDerivedStateFromError(error: Error): State { - return { error } - } - - componentDidCatch(error: Error, info: ErrorInfo) { - console.error('[editor/xr] Immersive render failed', error, info.componentStack) - } - - render() { - if (!this.state.error) return this.props.children - - return ( -
-
-

XR render error

-

The immersive scene could not render

-

- {this.state.error.message || 'An unknown WebXR rendering error occurred.'} -

- -
-
- ) - } -} diff --git a/apps/editor/components/xr/xr-runtime.tsx b/apps/editor/components/xr/xr-runtime.tsx deleted file mode 100644 index 9b50c78985..0000000000 --- a/apps/editor/components/xr/xr-runtime.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client' - -import { createViewerXRStore, type ViewerXRConfig, type ViewerXRStore } from '@pascal-app/viewer' -import { useEffect, useState } from 'react' -import { prepareXRPlatform, type XRRuntimeSource } from '@/lib/xr/emulator' - -type XRRuntimeState = - | { status: 'idle' | 'loading' } - | { message: string; status: 'error' } - | { source: XRRuntimeSource; status: 'ready'; store: ViewerXRStore } - | { status: 'unsupported' } - -export function useEditorXRRuntime(enabled: boolean): XRRuntimeState { - const [runtime, setRuntime] = useState({ status: 'idle' }) - - useEffect(() => { - if (!enabled) return - - let cancelled = false - setRuntime({ status: 'loading' }) - prepareXRPlatform() - .then((source) => { - if (cancelled) return - if (source === 'unsupported') { - setRuntime({ status: 'unsupported' }) - return - } - setRuntime({ - source, - status: 'ready', - // The editor uses ordinary spatial meshes for its wand and scene; - // disabling Layers avoids an unnecessary WebGL emulator framebuffer. - store: createViewerXRStore({ layers: false }), - }) - }) - .catch((error: unknown) => { - if (cancelled) return - setRuntime({ - message: error instanceof Error ? error.message : 'Could not initialize WebXR', - status: 'error', - }) - }) - - return () => { - cancelled = true - } - }, [enabled]) - - return runtime -} - -export async function requestEditorVRSession(store: ViewerXRStore): Promise { - if (!navigator.xr) throw new Error('Immersive VR is unavailable') - - const domOverlayRoot = store.getState().domOverlayRoot - return navigator.xr.requestSession('immersive-vr', { - requiredFeatures: ['local-floor'], - optionalFeatures: [ - 'anchors', - 'dom-overlay', - 'hand-tracking', - 'hit-test', - 'mesh-detection', - 'plane-detection', - ], - ...(domOverlayRoot ? { domOverlay: { root: domOverlayRoot } } : {}), - }) -} - -export function xrConfigForRuntime( - runtime: XRRuntimeState, - session?: XRSession, -): ViewerXRConfig | undefined { - return runtime.status === 'ready' - ? { multiview: false, playerModes: true, session, store: runtime.store } - : undefined -} - -export type { XRRuntimeState } diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 6550554181..7dc9474800 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -12,7 +12,6 @@ import { builtinPlugin } from '@pascal-app/nodes' import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones' import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape' import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' -import { webXRHostPanel, webXRPlugin } from '@pascal-local/plugin-webxr' // Each module evaluation loads builtins once; development reloads replace stale definitions. let builtinsLoaded = false @@ -96,8 +95,5 @@ registerEditorHostPanel({ ...streetscapeHostPanel, creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, }) -extendPluginDiscovery(async () => [webXRPlugin]) -registerEditorHostPanel(webXRHostPanel) - loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/lib/xr/editor-input.test.ts b/apps/editor/lib/xr/editor-input.test.ts deleted file mode 100644 index 7f739e22a8..0000000000 --- a/apps/editor/lib/xr/editor-input.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { - didXRButtonPressStart, - isXRCancelPressed, - pulseXRInputSource, - replayXRWallOpeningRelease, - resolveXRReleaseAction, - selectPrimaryXRInputSource, - shouldReleaseCapturedXRInput, - shouldRouteXRMove, - XRSelectReleaseGuard, -} from './editor-input' - -function inputSource({ - button5 = false, - handedness, - targetRayMode = 'tracked-pointer', -}: { - button5?: boolean - handedness: XRHandedness - targetRayMode?: XRTargetRayMode -}): XRInputSource { - return { - gamepad: { buttons: [{}, {}, {}, {}, {}, { pressed: button5 }] }, - handedness, - targetRayMode, - } as unknown as XRInputSource -} - -describe('XR editor input routing', () => { - test('defers empty-space deselection until node click listeners have run', async () => { - const right = inputSource({ handedness: 'right' }) - const guard = new XRSelectReleaseGuard() - let deselections = 0 - - guard.start(right) - guard.deferEmptyRelease(right, () => deselections++) - guard.markNodeClick(right) - await Promise.resolve() - expect(deselections).toBe(0) - - guard.start(right) - guard.deferEmptyRelease(right, () => deselections++) - await Promise.resolve() - expect(deselections).toBe(1) - }) - - test('cancels deferred deselection and keeps input-source cycles isolated', async () => { - const left = inputSource({ handedness: 'left' }) - const right = inputSource({ handedness: 'right' }) - const guard = new XRSelectReleaseGuard() - let deselections = 0 - - guard.start(right) - guard.deferEmptyRelease(right, () => deselections++) - guard.cancel(right) - await Promise.resolve() - expect(deselections).toBe(0) - - guard.start(right) - guard.deferEmptyRelease(right, () => deselections++) - guard.markNodeClick(left) - await Promise.resolve() - expect(deselections).toBe(1) - }) - - test('routes select, tool, and drag releases without cross-triggering deselection', () => { - expect( - resolveXRReleaseAction({ mode: 'select', placementDrag: false, scopeKind: 'idle' }), - ).toBe('defer-empty-selection') - expect( - resolveXRReleaseAction({ mode: 'select', placementDrag: false, scopeKind: 'handle-drag' }), - ).toBe('ignore') - expect(resolveXRReleaseAction({ mode: 'build', placementDrag: false, scopeKind: 'idle' })).toBe( - 'emit-tool-grid-click', - ) - expect( - resolveXRReleaseAction({ mode: 'material-paint', placementDrag: false, scopeKind: 'idle' }), - ).toBe('ignore') - expect(resolveXRReleaseAction({ mode: 'select', placementDrag: true, scopeKind: 'idle' })).toBe( - 'finish-placement-drag', - ) - }) - - test('restores a wall opening draft before committing an XR release', () => { - const wallEvent = { node: { id: 'wall_test' } } - const emitted: string[] = [] - let draftExists = false - let placements = 0 - - expect( - replayXRWallOpeningRelease(wallEvent, (suffix) => { - emitted.push(suffix) - if (suffix === 'move') draftExists = true - if (suffix === 'click' && draftExists) placements += 1 - }), - ).toBe(true) - expect(emitted).toEqual(['move', 'click']) - expect(placements).toBe(1) - }) - - test('keeps the input source that owns the active press', () => { - const left = inputSource({ handedness: 'left' }) - const right = inputSource({ handedness: 'right' }) - expect(selectPrimaryXRInputSource([left, right], left)).toBe(left) - }) - - test('prefers the right tracked pointer while idle', () => { - const left = inputSource({ handedness: 'left' }) - const right = inputSource({ handedness: 'right' }) - expect(selectPrimaryXRInputSource([left, right])).toBe(right) - }) - - test('maps the right controller B button to cancel on its rising edge', () => { - const right = inputSource({ button5: true, handedness: 'right' }) - expect(isXRCancelPressed([right])).toBe(true) - expect(didXRButtonPressStart(false, true)).toBe(true) - expect(didXRButtonPressStart(true, true)).toBe(false) - }) - - test('keeps a captured drag moving even when it crosses the wand panel', () => { - const left = inputSource({ handedness: 'left' }) - expect(shouldRouteXRMove(left, left, true)).toBe(true) - expect(shouldRouteXRMove(left, null, true)).toBe(false) - expect(shouldRouteXRMove(left, null, false)).toBe(true) - }) - - test('releases a captured source after it disconnects', () => { - const left = inputSource({ handedness: 'left' }) - const right = inputSource({ handedness: 'right' }) - expect(shouldReleaseCapturedXRInput([left, right], left)).toBe(false) - expect(shouldReleaseCapturedXRInput([right], left)).toBe(true) - expect(shouldReleaseCapturedXRInput([right], null)).toBe(false) - }) - - test('pulses supported haptics and ignores unsupported input sources', async () => { - const pulse = async () => true - const supported = { - gamepad: { hapticActuators: [{ pulse }] }, - } as unknown as XRInputSource - const unsupported = { gamepad: { buttons: [] } } as unknown as XRInputSource - - expect(pulseXRInputSource(supported)).toBe(true) - expect(pulseXRInputSource(unsupported)).toBe(false) - }) -}) diff --git a/apps/editor/lib/xr/editor-input.ts b/apps/editor/lib/xr/editor-input.ts deleted file mode 100644 index e6e91529ca..0000000000 --- a/apps/editor/lib/xr/editor-input.ts +++ /dev/null @@ -1,126 +0,0 @@ -export class XRSelectReleaseGuard { - private readonly cycles = new WeakMap() - - start(source: XRInputSource) { - this.cycles.set(source, { nodeClicked: false }) - } - - markNodeClick(source: XRInputSource) { - const cycle = this.cycles.get(source) - if (cycle) cycle.nodeClicked = true - } - - cancel(source: XRInputSource) { - this.cycles.delete(source) - } - - deferEmptyRelease(source: XRInputSource, onEmptyRelease: () => void) { - const cycle = this.cycles.get(source) - if (!cycle) return - - queueMicrotask(() => { - if (this.cycles.get(source) !== cycle) return - this.cycles.delete(source) - if (!cycle.nodeClicked) onEmptyRelease() - }) - } -} - -export type XRReleaseAction = - | 'defer-empty-selection' - | 'emit-tool-grid-click' - | 'finish-placement-drag' - | 'ignore' - -export function replayXRWallOpeningRelease( - event: T | null, - emit: (suffix: 'move' | 'click', event: T) => void, -): boolean { - if (!event) return false - emit('move', event) - emit('click', event) - return true -} - -export function resolveXRReleaseAction({ - mode, - placementDrag, - scopeKind, -}: { - mode: string - placementDrag: boolean - scopeKind: string -}): XRReleaseAction { - if (placementDrag) return 'finish-placement-drag' - // Paint is committed by the shared node click handler, just like desktop - // paint. Do not also route an empty XR release through grid tool logic. - if (mode === 'material-paint') return 'ignore' - if (mode !== 'select') return 'emit-tool-grid-click' - return scopeKind === 'idle' ? 'defer-empty-selection' : 'ignore' -} - -export function selectPrimaryXRInputSource( - inputSources: readonly XRInputSource[], - activeInputSource?: XRInputSource | null, -): XRInputSource | null { - if (activeInputSource && inputSources.includes(activeInputSource)) return activeInputSource - - return ( - inputSources.find( - (source) => source.handedness === 'right' && source.targetRayMode === 'tracked-pointer', - ) ?? - inputSources.find((source) => source.targetRayMode === 'tracked-pointer') ?? - null - ) -} - -export function shouldRouteXRMove( - source: XRInputSource | null, - capturedSource: XRInputSource | null, - panelHit: boolean, -): boolean { - if (!source) return false - return source === capturedSource || !panelHit -} - -export function shouldReleaseCapturedXRInput( - inputSources: readonly XRInputSource[], - capturedSource: XRInputSource | null, -): boolean { - return capturedSource != null && !inputSources.includes(capturedSource) -} - -export function isXRCancelPressed(inputSources: readonly XRInputSource[]): boolean { - const rightController = inputSources.find( - (source) => source.handedness === 'right' && source.gamepad != null, - ) - return rightController?.gamepad?.buttons[5]?.pressed === true -} - -export function didXRButtonPressStart(previousPressed: boolean, nextPressed: boolean): boolean { - return !previousPressed && nextPressed -} - -export function pulseXRInputSource( - source: XRInputSource, - intensity = 0.18, - durationMs = 25, -): boolean { - const actuator = ( - source.gamepad as - | (Gamepad & { - hapticActuators?: readonly { - pulse: (intensity: number, duration: number) => Promise - }[] - }) - | null - )?.hapticActuators?.[0] - if (!actuator) return false - - try { - void actuator.pulse(intensity, durationMs).catch(() => undefined) - return true - } catch { - return false - } -} diff --git a/apps/editor/lib/xr/emulator-ray.test.ts b/apps/editor/lib/xr/emulator-ray.test.ts deleted file mode 100644 index 5266d4cd31..0000000000 --- a/apps/editor/lib/xr/emulator-ray.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { Group, Vector3 } from 'three' -import { resolveEmulatedInputPose } from './emulator-ray' - -describe('emulated XR input pose', () => { - test('places the ray in front of a target in reference-space coordinates', () => { - const origin = new Group() - origin.position.set(10, 0, 0) - const target = new Group() - target.position.set(10, 1, -2) - - const pose = resolveEmulatedInputPose(target, origin, 0.5) - const direction = new Vector3(0, 0, -1).applyQuaternion({ - x: pose.quaternion[0], - y: pose.quaternion[1], - z: pose.quaternion[2], - w: pose.quaternion[3], - }) - - expect(pose.position).toEqual([0, 1, -1.5]) - expect(direction.toArray()).toEqual([0, 0, -1]) - }) -}) diff --git a/apps/editor/lib/xr/emulator-ray.ts b/apps/editor/lib/xr/emulator-ray.ts deleted file mode 100644 index 0d29e02cd4..0000000000 --- a/apps/editor/lib/xr/emulator-ray.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Matrix4, type Object3D, Quaternion, Vector3 } from 'three' - -export type EmulatedInputPose = { - position: [number, number, number] - quaternion: [number, number, number, number] -} - -const FORWARD = new Vector3(0, 0, -1) - -export function resolveEmulatedInputPose( - target: Object3D, - referenceOrigin: Object3D, - distance = 0.5, -): EmulatedInputPose { - target.updateWorldMatrix(true, false) - referenceOrigin.updateWorldMatrix(true, false) - const targetPosition = target.getWorldPosition(new Vector3()) - const targetNormal = new Vector3(0, 0, 1).transformDirection(target.matrixWorld) - const inputPosition = targetPosition.clone().addScaledVector(targetNormal, distance) - const rayDirection = targetNormal.negate() - const worldToReference = new Matrix4().copy(referenceOrigin.matrixWorld).invert() - inputPosition.applyMatrix4(worldToReference) - rayDirection.transformDirection(worldToReference) - const quaternion = new Quaternion().setFromUnitVectors(FORWARD, rayDirection) - return { - position: inputPosition.toArray(), - quaternion: quaternion.toArray(), - } -} diff --git a/apps/editor/lib/xr/emulator.ts b/apps/editor/lib/xr/emulator.ts deleted file mode 100644 index 3445f705e7..0000000000 --- a/apps/editor/lib/xr/emulator.ts +++ /dev/null @@ -1,74 +0,0 @@ -'use client' - -import { getImmersiveVRSupport } from '@pascal-app/viewer' -import type { XRDevice } from 'iwer' - -export type XRRuntimeSource = 'native' | 'emulated' | 'unsupported' - -const setupKey = '__pascalEditorIwerSetup' -const deviceKey = '__pascalEditorIwerDevice' - -type EmulatedXRDevice = { - canvasContainer: HTMLDivElement - devui?: { - devUICanvas: HTMLCanvasElement - devUIContainer: HTMLDivElement - } -} - -export function getEmulatedXRDevice(): XRDevice | undefined { - return (globalThis as GlobalWithIwerSetup)[deviceKey] as XRDevice | undefined -} - -type GlobalWithIwerSetup = typeof globalThis & { - [deviceKey]?: EmulatedXRDevice - [setupKey]?: Promise -} - -export function prepareXRPlatform(): Promise { - const runtimeGlobal = globalThis as GlobalWithIwerSetup - runtimeGlobal[setupKey] ??= setupXRPlatform().catch((error: unknown) => { - delete runtimeGlobal[setupKey] - throw error - }) - return runtimeGlobal[setupKey] -} - -async function setupXRPlatform(): Promise { - if ((await getImmersiveVRSupport()) === 'supported') return 'native' - if (process.env.NODE_ENV !== 'development') return 'unsupported' - - const [{ XRDevice, metaQuest3 }, { DevUI }] = await Promise.all([ - import('iwer'), - import('@iwer/devui'), - ]) - const device = new XRDevice(metaQuest3) - device.installRuntime({ forceInstall: true }) - device.installDevUI(DevUI) - ;(globalThis as GlobalWithIwerSetup)[deviceKey] = device - - return (await getImmersiveVRSupport()) === 'supported' ? 'emulated' : 'unsupported' -} - -export function mountEmulatorControls(): () => void { - const device = (globalThis as GlobalWithIwerSetup)[deviceKey] - const devui = device?.devui - if (!(device && devui)) return () => undefined - - const host = device.canvasContainer - const mountedHost = !host.isConnected - const mountedCanvas = !devui.devUICanvas.isConnected - const mountedControls = !devui.devUIContainer.isConnected - - if (mountedCanvas) host.appendChild(devui.devUICanvas) - if (mountedControls) host.appendChild(devui.devUIContainer) - if (mountedHost) document.body.appendChild(host) - - return () => { - if (mountedCanvas && devui.devUICanvas.parentElement === host) devui.devUICanvas.remove() - if (mountedControls && devui.devUIContainer.parentElement === host) { - devui.devUIContainer.remove() - } - if (mountedHost && host.isConnected && host.childElementCount === 0) host.remove() - } -} diff --git a/apps/editor/lib/xr/preview-window.test.ts b/apps/editor/lib/xr/preview-window.test.ts deleted file mode 100644 index 6df48606cc..0000000000 --- a/apps/editor/lib/xr/preview-window.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { createXRPreviewSceneSnapshot } from './preview-window' - -describe('XR preview scene handoff', () => { - test('copies the complete live editor graph into the XR snapshot', () => { - const wall = { id: 'wall_1', parentId: 'level_1', type: 'wall' } - const state = { - collections: { shell: { id: 'shell', nodeIds: ['wall_1'] } }, - installedPlugins: ['@pascal-app/plugin-example'], - materials: { plaster: { id: 'plaster' } }, - nodes: { - level_1: { children: ['wall_1'], id: 'level_1', type: 'level' }, - wall_1: wall, - }, - rootNodeIds: ['level_1'], - } - - expect(createXRPreviewSceneSnapshot(state)).toEqual(state) - expect(createXRPreviewSceneSnapshot(state).nodes.wall_1).toBe(wall) - }) - - test('runs room-surface synchronization in the standalone XR editor', async () => { - const source = await Bun.file( - new URL('../../components/xr/xr-preview-environment.tsx', import.meta.url), - ).text() - - expect(source).toContain('initSpaceDetectionSync(useScene, useEditor)') - expect(source).toContain('unsubscribeSpaceDetection()') - }) -}) diff --git a/apps/editor/lib/xr/preview-window.ts b/apps/editor/lib/xr/preview-window.ts deleted file mode 100644 index 055c5a29c3..0000000000 --- a/apps/editor/lib/xr/preview-window.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useScene } from '@pascal-app/core' -import type { SceneGraph } from '@pascal-app/editor' - -export const XR_PREVIEW_SCENE_KEY = 'pascal-xr-preview-scene' - -type XRPreviewSceneState = Pick< - ReturnType, - 'collections' | 'installedPlugins' | 'materials' | 'nodes' | 'rootNodeIds' -> - -export function createXRPreviewSceneSnapshot(state: XRPreviewSceneState): SceneGraph { - const { collections, installedPlugins, materials, nodes, rootNodeIds } = state - return { collections, installedPlugins, materials, nodes, rootNodeIds } as SceneGraph -} - -export function openXRPreview(path: string) { - try { - localStorage.setItem( - XR_PREVIEW_SCENE_KEY, - JSON.stringify(createXRPreviewSceneSnapshot(useScene.getState())), - ) - } catch {} - - const url = new URL(path, window.location.href) - url.searchParams.set('source', 'live') - const preview = window.open( - `${url.pathname}${url.search}${url.hash}`, - 'pascal-xr-preview', - 'popup=yes,width=1280,height=800,resizable=yes,scrollbars=no', - ) - preview?.focus() -} diff --git a/apps/editor/lib/xr/reference-space-ray.test.ts b/apps/editor/lib/xr/reference-space-ray.test.ts deleted file mode 100644 index eb1614dcdd..0000000000 --- a/apps/editor/lib/xr/reference-space-ray.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { Euler, Matrix4, Plane, Quaternion, Ray, Vector3 } from 'three' -import { applyXRReferenceSpaceRayToWorld, setObjectFloorPlane } from './reference-space-ray' - -describe('applyXRReferenceSpaceRayToWorld', () => { - test('moves and rotates a raw XR pose with the active XR origin', () => { - const originMatrix = new Matrix4().compose( - new Vector3(0, 4.5, 8), - new Quaternion().setFromEuler(new Euler(0, Math.PI / 2, 0)), - new Vector3(2, 2, 2), - ) - const rayOrigin = new Vector3(0.25, 1.5, -0.4) - const rayDirection = new Vector3(0, 0, -1) - - applyXRReferenceSpaceRayToWorld(rayOrigin, rayDirection, originMatrix) - - expect(rayOrigin.x).toBeCloseTo(-0.8) - expect(rayOrigin.y).toBeCloseTo(7.5) - expect(rayOrigin.z).toBeCloseTo(7.5) - expect(rayDirection.x).toBeCloseTo(-1) - expect(rayDirection.y).toBeCloseTo(0) - expect(rayDirection.z).toBeCloseTo(0) - }) - - test('intersects the transformed active-level floor instead of global Y zero', () => { - const levelMatrix = new Matrix4().makeTranslation(0, 4.5, 0) - const floor = new Plane() - setObjectFloorPlane(floor, levelMatrix, new Vector3(), new Vector3()) - - const ray = new Ray(new Vector3(0, 6, 8), new Vector3(-2.5, -1.5, -8).normalize()) - const hit = ray.intersectPlane(floor, new Vector3()) - - expect(hit?.x).toBeCloseTo(-2.5) - expect(hit?.y).toBeCloseTo(4.5) - expect(hit?.z).toBeCloseTo(0) - }) -}) diff --git a/apps/editor/lib/xr/reference-space-ray.ts b/apps/editor/lib/xr/reference-space-ray.ts deleted file mode 100644 index 8013426d1b..0000000000 --- a/apps/editor/lib/xr/reference-space-ray.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Matrix4, Plane, Vector3 } from 'three' - -export function applyXRReferenceSpaceRayToWorld( - origin: Vector3, - direction: Vector3, - originMatrix: Matrix4, -) { - origin.applyMatrix4(originMatrix) - direction.transformDirection(originMatrix) -} - -export function setObjectFloorPlane( - plane: Plane, - objectMatrix: Matrix4, - point: Vector3, - normal: Vector3, -) { - plane.setFromNormalAndCoplanarPoint( - normal.set(0, 1, 0).transformDirection(objectMatrix), - point.set(0, 0, 0).applyMatrix4(objectMatrix), - ) -} diff --git a/apps/editor/lib/xr/settings.test.ts b/apps/editor/lib/xr/settings.test.ts deleted file mode 100644 index 549aa82c71..0000000000 --- a/apps/editor/lib/xr/settings.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeDefinition, ParametricDescriptor } from '@pascal-app/core' -import { - collectXRSettingRows, - createXRSettingPatch, - readXRSettingValue, - type XRSettingsContext, -} from './settings' - -function context( - node: AnyNode, - parametrics: ParametricDescriptor, - source: 'node' | 'tool' = 'node', -): XRSettingsContext { - return { - definition: { parametrics } as AnyNodeDefinition, - key: `${source}:test`, - node, - source, - title: 'Test', - ...(source === 'tool' ? { tool: node.type } : {}), - } -} - -describe('XR settings descriptors', () => { - test('flattens visible fields, vector axes, and node actions into one pageable list', () => { - const node = { - id: 'test_1', - type: 'test', - position: [1, 2, 3], - visible: true, - } as unknown as AnyNode - const parametrics = { - actions: [{ label: 'Reset', onClick: () => undefined }], - groups: [ - { - fields: [ - { key: 'position', kind: 'vec3', label: 'Position' }, - { key: 'visible', kind: 'boolean', label: 'Visible' }, - { key: 'hidden', kind: 'number', visibleIf: () => false }, - ], - label: 'Transform', - }, - ], - } as unknown as ParametricDescriptor - - const rows = collectXRSettingRows(context(node, parametrics)) - expect(rows.map((row) => row.label)).toEqual([ - 'Position X', - 'Position Y', - 'Position Z', - 'Visible', - 'Reset', - ]) - }) - - test('creates vector patches without mutating the source node', () => { - const node = { id: 'test_1', type: 'test', position: [1, 2, 3] } as unknown as AnyNode - const parametrics = { - groups: [{ fields: [{ key: 'position', kind: 'vec3' }], label: 'Transform' }], - } as unknown as ParametricDescriptor - const settings = context(node, parametrics) - const row = collectXRSettingRows(settings)[1] - if (row?.kind !== 'field') throw new Error('Expected field row') - - expect(readXRSettingValue(settings, row)).toBe(2) - expect(createXRSettingPatch(settings, row, 9)).toEqual({ position: [1, 9, 3] }) - expect((node as unknown as { position: number[] }).position).toEqual([1, 2, 3]) - }) - - test('runs the shared derive rule for placement defaults', () => { - const node = { id: 'test_1', type: 'test', width: 2, area: 4 } as unknown as AnyNode - const parametrics = { - derive: (next: AnyNode) => ({ - area: Number((next as unknown as { width: number }).width) ** 2, - }), - groups: [{ fields: [{ key: 'width', kind: 'number' }], label: 'Size' }], - } as unknown as ParametricDescriptor - const settings = context(node, parametrics, 'tool') - const row = collectXRSettingRows(settings)[0] - if (row?.kind !== 'field') throw new Error('Expected field row') - - expect(createXRSettingPatch(settings, row, 3)).toEqual({ area: 9, width: 3 }) - }) - - test('includes registry tool chips for spatial placement controls', () => { - const node = { id: 'test_1', type: 'test' } as unknown as AnyNode - const chip = { - cycle: () => undefined, - labels: { cabinet: 'Type: Cabinet', island: 'Type: Island' }, - subscribe: () => () => undefined, - value: () => 'cabinet', - } - const settings = { - ...context(node, { groups: [] }, 'tool'), - definition: { - parametrics: { groups: [] }, - toolHints: [{ chip, key: 'I', label: 'Placement type' }], - } as unknown as AnyNodeDefinition, - } - - expect(collectXRSettingRows(settings)).toEqual([ - expect.objectContaining({ kind: 'tool-chip', label: 'Placement type' }), - ]) - }) -}) diff --git a/apps/editor/lib/xr/settings.ts b/apps/editor/lib/xr/settings.ts deleted file mode 100644 index c5d65fe221..0000000000 --- a/apps/editor/lib/xr/settings.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - type AnyNode, - type AnyNodeDefinition, - type AnyNodeId, - nodeRegistry, - type ParamAction, - type ParametricDescriptor, - type ParamField, - type ToolHint, -} from '@pascal-app/core' - -export type XRSettingsContext = { - definition: AnyNodeDefinition - key: string - node: AnyNode - source: 'node' | 'tool' - title: string - tool?: string -} - -export type XRSettingFieldRow = { - axis?: number - field: ParamField - group: string - id: string - kind: 'field' - label: string -} - -export type XRSettingActionRow = { - action: ParamAction - id: string - kind: 'action' - label: string -} - -export type XRSettingToolChipRow = { - hint: ToolHint & { chip: NonNullable } - id: string - kind: 'tool-chip' - label: string -} - -export type XRSettingRow = XRSettingActionRow | XRSettingFieldRow | XRSettingToolChipRow - -type ResolveXRSettingsContextInput = { - mode: string - selectedNode?: AnyNode - tool: string | null - toolDefaults?: Readonly> -} - -export function resolveXRSettingsContext({ - mode, - selectedNode, - tool, - toolDefaults, -}: ResolveXRSettingsContextInput): XRSettingsContext | null { - if (selectedNode) { - const definition = nodeRegistry.get(selectedNode.type) - if (!definition) return null - return { - definition, - key: `node:${selectedNode.id}`, - node: selectedNode, - source: 'node', - title: definition.presentation?.label ?? selectedNode.type, - } - } - - if (mode !== 'build' || !tool) return null - const definition = nodeRegistry.get(tool) - if (!definition) return null - const defaults = definition.defaults() as Record - const node = { - ...defaults, - ...toolDefaults, - id: `xr-tool-default:${tool}` as AnyNodeId, - type: tool, - } as AnyNode - return { - definition, - key: `tool:${tool}`, - node, - source: 'tool', - title: `${definition.presentation?.label ?? tool} defaults`, - tool, - } -} - -export function collectXRSettingRows(context: XRSettingsContext): XRSettingRow[] { - const parametrics = context.definition.parametrics as ParametricDescriptor | undefined - const rows: XRSettingRow[] = [] - - if (context.source === 'tool') { - context.definition.toolHints?.forEach((hint, index) => { - if (!hint.chip || (hint.visible && !hint.visible.value())) return - rows.push({ - hint: hint as ToolHint & { chip: NonNullable }, - id: `tool-chip-${index}-${hint.label}`, - kind: 'tool-chip', - label: hint.label, - }) - }) - } - - if (!parametrics) return rows - - parametrics.groups.forEach((group, groupIndex) => { - group.fields.forEach((rawField, fieldIndex) => { - const field = rawField as ParamField - if (field.visibleIf) { - try { - if (!field.visibleIf(context.node)) return - } catch { - return - } - } - const label = field.label ?? String(field.key) - const id = `${groupIndex}-${fieldIndex}-${String(field.key)}` - if (field.kind === 'vec3') { - for (let axis = 0; axis < 3; axis += 1) { - rows.push({ - axis, - field, - group: group.label, - id: `${id}-${axis}`, - kind: 'field', - label: `${label} ${'XYZ'[axis]}`, - }) - } - return - } - rows.push({ field, group: group.label, id, kind: 'field', label }) - }) - }) - - if (context.source === 'node') { - parametrics.actions?.forEach((action, index) => { - rows.push({ - action: action as ParamAction, - id: `action-${index}-${action.label}`, - kind: 'action', - label: action.label, - }) - }) - } - - return rows -} - -export function readXRSettingValue(context: XRSettingsContext, row: XRSettingFieldRow): unknown { - const value = (context.node as unknown as Record)[String(row.field.key)] - if (row.field.kind !== 'vec3') return value - return Array.isArray(value) ? value[row.axis ?? 0] : undefined -} - -export function createXRSettingPatch( - context: XRSettingsContext, - row: XRSettingFieldRow, - value: unknown, -): Record { - const key = String(row.field.key) - let patch: Record - if (row.field.kind === 'vec3') { - const current = (context.node as unknown as Record)[key] - const vector = Array.isArray(current) ? [...current] : [0, 0, 0] - vector[row.axis ?? 0] = value - patch = { [key]: vector } - } else { - patch = { [key]: value } - } - - if (context.source !== 'tool') return patch - const parametrics = context.definition.parametrics as ParametricDescriptor | undefined - if (!parametrics?.derive) return patch - const next = { ...context.node, ...patch } as AnyNode - return { - ...patch, - ...parametrics.derive(next, patch as Partial, context.node), - } -} diff --git a/apps/editor/lib/xr/wand-panel-settings.test.ts b/apps/editor/lib/xr/wand-panel-settings.test.ts deleted file mode 100644 index 30bc7fe1e3..0000000000 --- a/apps/editor/lib/xr/wand-panel-settings.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test' -import { - useXRWandPanelSettings, - XR_WAND_PANEL_SCALE_MAX, - XR_WAND_PANEL_SCALE_MIN, -} from './wand-panel-settings' - -describe('XR wand panel settings', () => { - beforeEach(() => - useXRWandPanelSettings.setState({ - buildPage: 0, - buildSection: 'main', - paintCategoryIndex: 0, - paintPage: 0, - panelScale: 1, - settingsContextKey: '', - settingsPage: 0, - terrainPage: 0, - }), - ) - - test('preserves build navigation outside the remounting input subtree', () => { - const { setBuildNavigation } = useXRWandPanelSettings.getState() - - setBuildNavigation('roof', 2) - expect(useXRWandPanelSettings.getState()).toMatchObject({ - buildPage: 2, - buildSection: 'roof', - }) - - setBuildNavigation('main', -1) - expect(useXRWandPanelSettings.getState()).toMatchObject({ - buildPage: 0, - buildSection: 'main', - }) - }) - - test('preserves every paginated panel across input remounts', () => { - const { setPaintNavigation, setSettingsNavigation, setTerrainPage } = - useXRWandPanelSettings.getState() - - setPaintNavigation(2, 3) - setSettingsNavigation('wall:wall-1', 4) - setTerrainPage(1) - - expect(useXRWandPanelSettings.getState()).toMatchObject({ - paintCategoryIndex: 2, - paintPage: 3, - settingsContextKey: 'wall:wall-1', - settingsPage: 4, - terrainPage: 1, - }) - }) - - test('updates panel size in stable decimal steps', () => { - const { setPanelScale } = useXRWandPanelSettings.getState() - - setPanelScale(1.1) - expect(useXRWandPanelSettings.getState().panelScale).toBe(1.1) - setPanelScale(1.200_000_000_000_000_2) - expect(useXRWandPanelSettings.getState().panelScale).toBe(1.2) - }) - - test('clamps the reference project scale range and rejects non-finite input', () => { - const { setPanelScale } = useXRWandPanelSettings.getState() - - setPanelScale(99) - expect(useXRWandPanelSettings.getState().panelScale).toBe(XR_WAND_PANEL_SCALE_MAX) - setPanelScale(0) - expect(useXRWandPanelSettings.getState().panelScale).toBe(XR_WAND_PANEL_SCALE_MIN) - setPanelScale(Number.NaN) - expect(useXRWandPanelSettings.getState().panelScale).toBe(XR_WAND_PANEL_SCALE_MIN) - }) -}) diff --git a/apps/editor/lib/xr/wand-panel-settings.ts b/apps/editor/lib/xr/wand-panel-settings.ts deleted file mode 100644 index 2c09b5bf7f..0000000000 --- a/apps/editor/lib/xr/wand-panel-settings.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { create } from 'zustand' - -export const XR_WAND_PANEL_SCALE_MIN = 0.65 -export const XR_WAND_PANEL_SCALE_MAX = 1.6 -export const XR_WAND_PANEL_SCALE_STEP = 0.1 - -export type XRWandBuildSection = 'main' | 'mep' | 'roof' - -type XRWandPanelSettingsState = { - buildPage: number - buildSection: XRWandBuildSection - paintCategoryIndex: number - paintPage: number - panelScale: number - settingsContextKey: string - settingsPage: number - terrainPage: number - setBuildNavigation: (buildSection: XRWandBuildSection, buildPage: number) => void - setPaintNavigation: (paintCategoryIndex: number, paintPage: number) => void - setPanelScale: (panelScale: number) => void - setSettingsNavigation: (settingsContextKey: string, settingsPage: number) => void - setTerrainPage: (terrainPage: number) => void -} - -export const useXRWandPanelSettings = create((set) => ({ - buildPage: 0, - buildSection: 'main', - paintCategoryIndex: 0, - paintPage: 0, - panelScale: 1, - settingsContextKey: '', - settingsPage: 0, - terrainPage: 0, - setBuildNavigation: (buildSection, buildPage) => { - set({ buildPage: Math.max(0, Math.floor(buildPage)), buildSection }) - }, - setPaintNavigation: (paintCategoryIndex, paintPage) => { - set({ - paintCategoryIndex: Math.max(0, Math.floor(paintCategoryIndex)), - paintPage: Math.max(0, Math.floor(paintPage)), - }) - }, - setPanelScale: (panelScale) => { - if (!Number.isFinite(panelScale)) return - set({ - panelScale: Math.min( - XR_WAND_PANEL_SCALE_MAX, - Math.max(XR_WAND_PANEL_SCALE_MIN, Math.round(panelScale * 100) / 100), - ), - }) - }, - setSettingsNavigation: (settingsContextKey, settingsPage) => { - set({ settingsContextKey, settingsPage: Math.max(0, Math.floor(settingsPage)) }) - }, - setTerrainPage: (terrainPage) => { - set({ terrainPage: Math.max(0, Math.floor(terrainPage)) }) - }, -})) diff --git a/apps/editor/lib/xr/wand-panel.test.ts b/apps/editor/lib/xr/wand-panel.test.ts deleted file mode 100644 index be8ab65d23..0000000000 --- a/apps/editor/lib/xr/wand-panel.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { Euler, Vector3 } from 'three' -import { - getPage, - getPageWithPinnedFirst, - resolveWandPanelFacePose, -} from '@/components/xr/wand-panel/panel-layout' - -describe('XR wand panel layout', () => { - test('does not mount Drei line or text materials in the immersive panel', async () => { - const files = [ - 'build-panel.tsx', - 'paint-panel.tsx', - 'settings-panel.tsx', - 'spatial-controls.tsx', - 'wand-panel.tsx', - ] - const sources = await Promise.all( - files.map((file) => - Bun.file(new URL(`../../components/xr/wand-panel/${file}`, import.meta.url)).text(), - ), - ) - - expect(sources.join('\n')).not.toMatch( - /import\s*\{[^}]*(?:\bLine\b|\bText\b)[^}]*\}\s*from\s*['"]@react-three\/drei['"]/, - ) - }) - - test('keeps panel switching controls out of the panel faces', async () => { - const source = await Bun.file( - new URL('../../components/xr/wand-panel/wand-panel.tsx', import.meta.url), - ).text() - - expect(source).not.toContain('RingArrows') - expect(source).toContain('pointerEventsOrder={100}') - expect(source).toContain("pointerEventsType={{ deny: 'grab' }}") - }) - - test('puts spatial button handlers on the raycastable mesh', async () => { - const source = await Bun.file( - new URL('../../components/xr/wand-panel/spatial-controls.tsx', import.meta.url), - ).text() - const buttonSource = source.slice( - source.indexOf('export function SpatialButton'), - source.indexOf('export function PanelFace'), - ) - - expect(buttonSource).toMatch(/ { - const source = await Bun.file( - new URL('../../components/xr/wand-panel/build-panel.tsx', import.meta.url), - ).text() - - expect(source).toContain("iconSrc: '/icons/select.webp'") - expect(source.match(/selectEntry,/g)).toHaveLength(3) - expect( - getPageWithPinnedFirst(['select', ...Array.from({ length: 17 }, (_, i) => i)], 1, 9), - ).toEqual({ - currentPage: 1, - items: ['select', 8, 9, 10, 11, 12, 13, 14, 15], - pageCount: 3, - }) - }) - - test('mirrors the ring faces for the opposite hand', () => { - const left = resolveWandPanelFacePose(1, 'left') - const right = resolveWandPanelFacePose(1, 'right') - - expect(right.position[0]).toBeCloseTo(-left.position[0]) - expect(right.position[1]).toBeCloseTo(left.position[1]) - expect(right.rotation[1]).toBeCloseTo(-left.rotation[1]) - }) - - test('matches the reference three-face ring at 120 degrees', () => { - const normals = [0, 1, 2].map((index) => { - const pose = resolveWandPanelFacePose(index, 'left') - expect(pose.position[2]).toBeCloseTo(0) - const normal = new Vector3(0, 0, 1).applyEuler(new Euler(...pose.rotation)) - expect(normal.z).toBeCloseTo(0) - return normal - }) - - expect(normals[0]!.dot(normals[1]!)).toBeCloseTo(-0.5) - expect(normals[1]!.dot(normals[2]!)).toBeCloseTo(-0.5) - expect(normals[2]!.dot(normals[0]!)).toBeCloseTo(-0.5) - }) - - test('clamps nested palette pages', () => { - expect(getPage([1, 2, 3, 4, 5], 9, 2)).toEqual({ - currentPage: 2, - items: [5], - pageCount: 3, - }) - }) -}) diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 3ac05105c5..c2250ef26b 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -46,7 +46,6 @@ const nextConfig: NextConfig = { '@pascal-app/core': '../../packages/core/src/index.ts', '@pascal-app/editor': '../../packages/editor/src/index.tsx', '@pascal-app/viewer': '../../packages/viewer/src/index.ts', - '@pascal-local/plugin-webxr': '../../../webxr-pascal-plugin/src/index.ts', react: '../../node_modules/react', three: '../../node_modules/three', // TSL and the renderer must share one module-level shader stack. diff --git a/apps/editor/package.json b/apps/editor/package.json index 7a2e26d1a2..31b4cde8a5 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -5,7 +5,6 @@ "private": true, "scripts": { "dev": "dotenv -e ../../.env.local -e ../../.env.defaults -- next dev", - "dev:xr": "dotenv -e ../../.env.local -e ../../.env.defaults -- next dev --hostname 0.0.0.0 --experimental-https", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", @@ -27,7 +26,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", - "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "geist": "^1.7.0", @@ -42,14 +40,12 @@ "zod": ">=4.5.4 <4.6" }, "devDependencies": { - "@iwer/devui": "2.3.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", - "iwer": "2.3.0", "react-grab": "^0.1.50", "react-scan": "^0.5.7", "tw-animate-css": "^1.4.0", diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index f3087c89dc..18b620d198 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -11,7 +11,6 @@ "@pascal-app/core": ["../../packages/core/src/index.ts"], "@pascal-app/editor": ["../../packages/editor/src/index.tsx"], "@pascal-app/viewer": ["../../packages/viewer/src/index.ts"], - "@pascal-local/plugin-webxr": ["../../../webxr-pascal-plugin/src/index.ts"] } }, "include": [ diff --git a/package.json b/package.json index d061e242b0..f1ba18f657 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "scripts": { "build": "turbo run build", "dev": "dotenv -e ./.env -e ./.env.defaults -- turbo run dev --env-mode=loose", - "dev:xr": "bun run --cwd apps/editor dev:xr", "lint": "biome lint", "lint:fix": "biome lint --write", "format": "biome format --write", @@ -60,7 +59,6 @@ }, "patchedDependencies": { "three@0.185.1": "patches/three@0.185.1.patch", - "iwer@2.3.0": "patches/iwer@2.3.0.patch" }, "workspaces": [ "apps/*", diff --git a/packages/viewer/package.json b/packages/viewer/package.json index b97ce99a05..4097c35b9a 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -31,7 +31,6 @@ "three": "^0.185" }, "dependencies": { - "@react-three/xr": "^6.6.30", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5" diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 55029cf950..dfe04a2e66 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -282,13 +282,3 @@ export { } from './systems/window/window-animation-system' export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system' export { ZoneSystem } from './systems/zone/zone-system' -export { requestGodScaleReset, useGodScaleView } from './xr/god-mode' -export { - toggleXRPlayerMode, - useXRPlayerMode, - XR_PLAYER_MODES, - type XRPlayerMode, -} from './xr/mode-switching' -export { useImmersiveXRPresentation } from './xr/presentation-context' -export { createViewerXRStore, type ViewerXRStore } from './xr/store' -export { getImmersiveVRSupport, type ImmersiveVRSupport } from './xr/support' diff --git a/packages/viewer/src/xr/distance-aware-ray-pointer.tsx b/packages/viewer/src/xr/distance-aware-ray-pointer.tsx deleted file mode 100644 index b40283fe2c..0000000000 --- a/packages/viewer/src/xr/distance-aware-ray-pointer.tsx +++ /dev/null @@ -1,162 +0,0 @@ -'use client' - -import { createPortal, useFrame, useThree } from '@react-three/fiber' -import { - type DefaultXRInputSourceRayPointerOptions, - usePointerXRInputSourceEvents, - useRayPointer, - useXRInputSourceStateContext, - XRSpace, -} from '@react-three/xr' -import { useEffect, useMemo, useRef } from 'react' -import { type Layers, type Mesh, type Object3D, Quaternion, RingGeometry, Vector3 } from 'three' -import { BATCHED_LAYER, OVERLAY_LAYER, ZONE_LAYER } from '../lib/layers' -import { - POINTER_CURSOR_INNER_RADIUS, - POINTER_CURSOR_OUTER_RADIUS, - resolvePointerCursorSize, -} from './pointer-cursor' -import { PointerRingMaterial } from './pointer-ring-material' - -const NEAR_RAY_HIDE_DISTANCE = 0.2 -const Z_AXIS = new Vector3(0, 0, 1) -const ignoreRaycast = () => null - -type RayIntersectorWithLayers = { - raycaster?: { layers: Layers } -} - -export function DistanceAwareRayPointer({ - options, -}: { - options: DefaultXRInputSourceRayPointerOptions -}) { - const state = useXRInputSourceStateContext() - const space = useRef(null) - const rayModel = useRef(null) - const cursorModel = useRef(null) - const scene = useThree((current) => current.scene) - const cursorMaterial = useMemo(() => new PointerRingMaterial(), []) - const cursorGeometry = useMemo( - () => new RingGeometry(POINTER_CURSOR_INNER_RADIUS, POINTER_CURSOR_OUTER_RADIUS, 32), - [], - ) - const normalQuaternion = useRef(new Quaternion()) - const objectQuaternion = useRef(new Quaternion()) - const cursorOffset = useRef(new Vector3()) - const pointer = useRayPointer(space, state, { ...options, makeDefault: true }) - const rayModelOptions = typeof options.rayModel === 'object' ? options.rayModel : undefined - const cursorModelOptions = - typeof options.cursorModel === 'object' ? options.cursorModel : undefined - - usePointerXRInputSourceEvents(pointer, state.inputSource, 'select', state.events) - useEffect(() => () => cursorMaterial.dispose(), [cursorMaterial]) - useEffect(() => () => cursorGeometry.dispose(), [cursorGeometry]) - useEffect(() => { - const layers = (pointer.intersector as unknown as RayIntersectorWithLayers).raycaster?.layers - if (!layers) return - const mask = layers.mask - layers.enable(BATCHED_LAYER) - layers.enable(OVERLAY_LAYER) - layers.enable(ZONE_LAYER) - return () => { - layers.mask = mask - } - }, [pointer]) - - useFrame(() => { - const intersection = pointer.getIntersection() - const distance = intersection?.distance - if ( - !intersection || - distance == null || - !pointer.getEnabled() || - (intersection.object as Object3D & { isVoidObject?: boolean }).isVoidObject === true - ) { - if (rayModel.current) rayModel.current.visible = false - if (cursorModel.current) cursorModel.current.visible = false - return - } - - if (rayModel.current) { - rayModel.current.visible = distance >= NEAR_RAY_HIDE_DISTANCE - const rayLength = Math.min(rayModelOptions?.maxLength ?? distance, distance) - rayModel.current.position.z = -rayLength / 2 - const raySize = rayModelOptions?.size ?? 0.005 - rayModel.current.scale.set(raySize, raySize, rayLength) - } - - if (!cursorModel.current) return - cursorModel.current.visible = true - cursorModel.current.position.copy(intersection.pointOnFace) - const normal = intersection.normal ?? intersection.face?.normal - if (normal) { - normalQuaternion.current.setFromUnitVectors(Z_AXIS, normal) - intersection.object.getWorldQuaternion(objectQuaternion.current) - cursorModel.current.quaternion - .copy(objectQuaternion.current) - .multiply(normalQuaternion.current) - cursorOffset.current - .set(0, 0, cursorModelOptions?.cursorOffset ?? 0.008) - .applyQuaternion(cursorModel.current.quaternion) - cursorModel.current.position.add(cursorOffset.current) - } - cursorModel.current.scale.setScalar(resolvePointerCursorSize(distance)) - cursorModel.current.updateMatrix() - - if (cursorModelOptions) { - const color = - typeof cursorModelOptions.color === 'function' - ? cursorModelOptions.color(pointer) - : cursorModelOptions.color - if (Array.isArray(color)) cursorMaterial.color.set(...color) - else cursorMaterial.color.set(color ?? 'white') - cursorMaterial.opacity = - typeof cursorModelOptions.opacity === 'function' - ? cursorModelOptions.opacity(pointer) - : (cursorModelOptions.opacity ?? 0.4) - } - }) - - const rayColor = - typeof rayModelOptions?.color === 'function' - ? rayModelOptions.color(pointer) - : (rayModelOptions?.color ?? 'white') - - return ( - - {options.rayModel !== false && ( - - - - - )} - {createPortal( - - - - , - scene, - )} - - ) -} diff --git a/packages/viewer/src/xr/frame-loop.test.ts b/packages/viewer/src/xr/frame-loop.test.ts deleted file mode 100644 index 167999af63..0000000000 --- a/packages/viewer/src/xr/frame-loop.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, mock, test } from 'bun:test' -import { - advanceXRFrameWithoutDesktopRender, - ownsXRFrameLoopBinding, - renderImmersiveXRFrame, - shouldMountPostProcessingRenderDriver, - shouldPauseFrameLimiterForXR, - stopXRFrameLoop, - takeOverXRFrameLoop, - unifyXRStereoCameraLayers, -} from './frame-loop' - -describe('takeOverXRFrameLoop', () => { - test('a superseded binding cannot restore over the current XR frame loop', () => { - const staleBinding = Symbol('stale') - const currentBinding = Symbol('current') - - expect(ownsXRFrameLoopBinding(currentBinding, staleBinding)).toBe(false) - expect(ownsXRFrameLoopBinding(currentBinding, currentBinding)).toBe(true) - }) - - test('advances R3F effects without issuing its desktop-camera render', () => { - const state = { internal: { priority: 0 } } - let priorityDuringAdvance = 0 - - advanceXRFrameWithoutDesktopRender(state, () => { - priorityDuringAdvance = state.internal.priority - }) - - expect(priorityDuringAdvance).toBe(1) - expect(state.internal.priority).toBe(0) - }) - - test('restores R3F render priority when frame advancement fails', () => { - const state = { internal: { priority: 2 } } - - expect(() => - advanceXRFrameWithoutDesktopRender(state, () => { - throw new Error('advance failed') - }), - ).toThrow('advance failed') - expect(state.internal.priority).toBe(2) - }) - - test('pauses the desktop frame loop as soon as an XR session is supplied', () => { - expect(shouldPauseFrameLimiterForXR(false, {} as XRSession)).toBe(true) - expect(shouldPauseFrameLimiterForXR(false, undefined)).toBe(false) - expect(shouldPauseFrameLimiterForXR(true, undefined)).toBe(true) - }) - - test('leaves XR rendering exclusively to Three’s XR animation loop', () => { - expect(shouldMountPostProcessingRenderDriver(true)).toBe(false) - expect(shouldMountPostProcessingRenderDriver(false)).toBe(true) - }) - - test('updates the stereo camera before drawing the immersive scene', () => { - const calls: string[] = [] - const xrOrigin = { type: 'xr-origin' } - const xrCamera = { parent: xrOrigin, type: 'xr-camera' } - const appCamera = { parent: null, type: 'app-camera' } - const renderer = { - render: mock((_scene: unknown, camera: unknown) => { - expect(camera).toBe(appCamera) - expect(appCamera.parent).toBeNull() - calls.push('render') - }), - xr: { - cameraAutoUpdate: true, - getCamera: mock(() => { - calls.push('get-camera') - return xrCamera - }), - updateCamera: mock(() => { - expect(appCamera.parent).toBe(xrOrigin) - calls.push('update-camera') - }), - }, - } - - renderImmersiveXRFrame(renderer, { type: 'scene' }, appCamera) - - expect(calls).toEqual(['get-camera', 'update-camera', 'render']) - expect(renderer.xr.cameraAutoUpdate).toBe(true) - }) - - test('restores the application camera parent when stereo updating fails', () => { - const appParent = { type: 'app-parent' } - const appCamera = { parent: appParent } - const renderer = { - render: mock(() => undefined), - xr: { - cameraAutoUpdate: true, - getCamera: mock(() => ({ parent: { type: 'xr-origin' } })), - updateCamera: mock(() => { - throw new Error('update failed') - }), - }, - } - - expect(() => renderImmersiveXRFrame(renderer, {}, appCamera)).toThrow('update failed') - expect(appCamera.parent).toBe(appParent) - }) - - test('restores automatic camera updates when an XR draw fails', () => { - const renderer = { - render: mock(() => { - throw new Error('draw failed') - }), - xr: { - cameraAutoUpdate: true, - getCamera: mock(() => ({ type: 'xr-camera' })), - updateCamera: mock(() => undefined), - }, - } - - expect(() => renderImmersiveXRFrame(renderer, {}, {})).toThrow('draw failed') - expect(renderer.xr.cameraAutoUpdate).toBe(true) - }) - - test('renders overlay and zone layers in both stereo eyes', () => { - const left = { layers: { mask: 0b1011 } } - const right = { layers: { mask: 0b1101 } } - const camera = { cameras: [left, right], layers: { mask: 0b1111 } } - - unifyXRStereoCameraLayers(camera) - - expect(left.layers.mask).toBe(0b1111) - expect(right.layers.mask).toBe(0b1111) - }) - - test('disconnects R3F and installs the renderer-owned XR frame loop', async () => { - const calls: string[] = [] - const setAnimationLoop = mock(async (callback: XRFrameRequestCallback | null) => { - calls.push(callback ? 'set-loop' : 'clear-loop') - }) - const renderer = { - setAnimationLoop, - setPixelRatio: mock((dpr: number) => calls.push(`dpr:${dpr}`)), - setSize: mock((width: number, height: number) => calls.push(`size:${width}x${height}`)), - xr: { enabled: false, isPresenting: false }, - } - const r3fXR = { - disconnect: mock(() => calls.push('disconnect')), - } - const renderFrame = (() => undefined) as XRFrameRequestCallback - - const restore = await takeOverXRFrameLoop(renderer, r3fXR, renderFrame, { - dpr: 1.5, - height: 800, - width: 936, - }) - - expect(calls).toEqual(['disconnect', 'dpr:1.5', 'size:936x800', 'set-loop']) - expect(renderer.xr.enabled).toBe(true) - expect(setAnimationLoop).toHaveBeenCalledWith(renderFrame) - - restore() - - expect(calls).toEqual(['disconnect', 'dpr:1.5', 'size:936x800', 'set-loop', 'clear-loop']) - expect(renderer.xr.enabled).toBe(false) - }) - - test('keeps Three’s XR wrapper alive until the presenting session ends', async () => { - const setAnimationLoop = mock(async () => undefined) - const renderer = { - setAnimationLoop, - setPixelRatio: mock(() => undefined), - setSize: mock(() => undefined), - xr: { enabled: false, isPresenting: false }, - } - - const restore = await takeOverXRFrameLoop( - renderer, - null, - (() => undefined) as XRFrameRequestCallback, - { dpr: 1, height: 800, width: 1280 }, - ) - renderer.xr.isPresenting = true - restore() - - expect(setAnimationLoop).toHaveBeenCalledTimes(1) - expect(renderer.xr.enabled).toBe(true) - - renderer.xr.isPresenting = false - stopXRFrameLoop(renderer) - - expect(setAnimationLoop).toHaveBeenLastCalledWith(null) - expect(renderer.xr.enabled).toBe(false) - }) -}) diff --git a/packages/viewer/src/xr/frame-loop.ts b/packages/viewer/src/xr/frame-loop.ts deleted file mode 100644 index 0ce659bd4b..0000000000 --- a/packages/viewer/src/xr/frame-loop.ts +++ /dev/null @@ -1,135 +0,0 @@ -export type XRFrameLoopRenderer = { - setAnimationLoop(callback: XRFrameRequestCallback | null): Promise | void - setPixelRatio(dpr: number): void - setSize(width: number, height: number, updateStyle?: boolean): void - xr: { - enabled: boolean - isPresenting: boolean - } -} - -type XRViewport = { - dpr: number - height: number - width: number -} - -type R3FXRConnection = { - disconnect(): void -} - -type XRRenderDriverRenderer = { - render(scene: unknown, camera: unknown): void - xr: { - cameraAutoUpdate: boolean - getCamera(): unknown - updateCamera(camera: unknown): void - } -} - -type XRUnionCamera = { - cameras?: { layers?: { mask: number } }[] - layers?: { mask: number } - parent?: unknown | null -} - -type XRBaseCamera = { - parent?: unknown | null -} - -type R3FFrameState = { - internal: { priority: number } -} - -export function advanceXRFrameWithoutDesktopRender(state: R3FFrameState, advanceFrame: () => void) { - const renderPriority = state.internal.priority - state.internal.priority = renderPriority + 1 - try { - advanceFrame() - } finally { - state.internal.priority = renderPriority - } -} - -export function unifyXRStereoCameraLayers(camera: XRUnionCamera) { - const mask = camera.layers?.mask - if (mask === undefined) return - // Three reserves layers 1 and 2 for left/right-eye visibility and removes - // one from each sub-camera. Pascal uses those layers for overlays and zones, - // so direct immersive presentation must render the union in both eyes. - for (const subCamera of camera.cameras ?? []) { - if (subCamera.layers) subCamera.layers.mask = mask - } -} - -export function shouldPauseFrameLimiterForXR(paused: boolean, session?: XRSession) { - return paused || session != null -} - -export function ownsXRFrameLoopBinding(activeBinding: symbol | null, binding: symbol) { - return activeBinding === binding -} - -export function shouldMountPostProcessingRenderDriver(immersiveXR: boolean) { - return !immersiveXR -} - -export function renderImmersiveXRFrame( - renderer: XRRenderDriverRenderer, - scene: unknown, - camera: unknown, -) { - const xrCamera = renderer.xr.getCamera() as XRUnionCamera - const baseCamera = camera as XRBaseCamera - const originalParent = baseCamera.parent - - // Three derives the stereo eye matrices from the parent of the application - // camera passed to updateCamera(). During our renderer-owned XR loop that is - // the preserved desktop camera, while parents the XR ArrayCamera. - // Borrow the ArrayCamera's origin only for the update so tracked inputs and - // both eyes are evaluated in the same world space. - if (xrCamera.parent != null) baseCamera.parent = xrCamera.parent - try { - renderer.xr.updateCamera(camera) - } finally { - baseCamera.parent = originalParent - } - unifyXRStereoCameraLayers(xrCamera) - const cameraAutoUpdate = renderer.xr.cameraAutoUpdate - renderer.xr.cameraAutoUpdate = false - try { - // Pass the application's base camera. With cameraAutoUpdate disabled the - // renderer's XR path substitutes the already-updated stereo camera itself; - // passing that ArrayCamera back as the base camera corrupts the second eye. - renderer.render(scene, camera) - } finally { - renderer.xr.cameraAutoUpdate = cameraAutoUpdate - } -} - -export async function takeOverXRFrameLoop( - renderer: XRFrameLoopRenderer, - r3fXR: R3FXRConnection | null, - renderFrame: XRFrameRequestCallback, - viewport: XRViewport, -) { - // R3F 9.6 still drives the legacy WebGL XR manager. Three's unified - // renderer owns its XR loop instead, so the viewer supplies R3F's frame - // callback through the renderer and disconnects the incompatible listener. - r3fXR?.disconnect() - renderer.setPixelRatio(viewport.dpr) - renderer.setSize(viewport.width, viewport.height, false) - renderer.xr.enabled = true - await renderer.setAnimationLoop(renderFrame) - - return () => { - if (renderer.xr.isPresenting) return - renderer.xr.enabled = false - void renderer.setAnimationLoop(null) - } -} - -export function stopXRFrameLoop(renderer: XRFrameLoopRenderer) { - renderer.xr.enabled = false - void renderer.setAnimationLoop(null) -} diff --git a/packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts b/packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts deleted file mode 100644 index 0bbf567a27..0000000000 --- a/packages/viewer/src/xr/god-mode/constants/god-mode-constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Euler, Vector3 } from 'three' - -export const GOD_ORIGIN_ROTATION = new Euler(0, 0, 0) -export const GOD_ORIGIN_POSITION = new Vector3(0, 4.5, 8) diff --git a/packages/viewer/src/xr/god-mode/index.ts b/packages/viewer/src/xr/god-mode/index.ts deleted file mode 100644 index 9aa086e3e5..0000000000 --- a/packages/viewer/src/xr/god-mode/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { GOD_ORIGIN_POSITION, GOD_ORIGIN_ROTATION } from './constants/god-mode-constants' -export { requestGodScaleReset, useGodScaleView } from './store/god-mode-view-store' -export { GodModeControls } from './ui/god-mode-controls' diff --git a/packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx b/packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx deleted file mode 100644 index 8ea0e122fe..0000000000 --- a/packages/viewer/src/xr/god-mode/input/god-mode-hand-controls.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client' - -import { useFrame } from '@react-three/fiber' -import { useXRInputSourceStateContext, XRSpace } from '@react-three/xr' -import { useEffect, useRef } from 'react' -import { type Object3D, Vector3 } from 'three' -import { advancePalmGrab, type PalmGrabPose } from '../lib/palm-grab' -import { clearGodScaleHandState, updateGodScaleHandState } from '../store/god-mode-hand-store' - -export function GodModeHandControls() { - const state = useXRInputSourceStateContext('hand') - const wrist = useRef(null) - const middleFingerTip = useRef(null) - const middleMetacarpal = useRef(null) - const ringMetacarpal = useRef(null) - const ringFingerTip = useRef(null) - const pinkyMetacarpal = useRef(null) - const pinkyFingerTip = useRef(null) - const wristPosition = useRef(new Vector3()) - const middleMetacarpalPosition = useRef(new Vector3()) - const middleFingerPosition = useRef(new Vector3()) - const ringMetacarpalPosition = useRef(new Vector3()) - const ringFingerPosition = useRef(new Vector3()) - const pinkyMetacarpalPosition = useRef(new Vector3()) - const pinkyFingerPosition = useRef(new Vector3()) - const palmGrabPosition = useRef(new Vector3()) - const palmGrabState = useRef({ elapsed: 0, grabbed: false }) - const palmGrabPose = useRef(null) - const handedness = state.inputSource.handedness - - useEffect(() => () => clearGodScaleHandState(handedness), [handedness]) - - useFrame((_, delta) => { - if (wrist.current?.visible) wrist.current.getWorldPosition(wristPosition.current) - if (middleMetacarpal.current?.visible) { - middleMetacarpal.current.getWorldPosition(middleMetacarpalPosition.current) - } - if (middleFingerTip.current?.visible) { - middleFingerTip.current.getWorldPosition(middleFingerPosition.current) - } - if (ringMetacarpal.current?.visible) { - ringMetacarpal.current.getWorldPosition(ringMetacarpalPosition.current) - } - if (ringFingerTip.current?.visible) { - ringFingerTip.current.getWorldPosition(ringFingerPosition.current) - } - if (pinkyMetacarpal.current?.visible) { - pinkyMetacarpal.current.getWorldPosition(pinkyMetacarpalPosition.current) - } - if (pinkyFingerTip.current?.visible) { - pinkyFingerTip.current.getWorldPosition(pinkyFingerPosition.current) - } - - const tracked = Boolean( - wrist.current?.visible && - middleMetacarpal.current?.visible && - middleFingerTip.current?.visible && - ringMetacarpal.current?.visible && - ringFingerTip.current?.visible && - pinkyMetacarpal.current?.visible && - pinkyFingerTip.current?.visible, - ) - palmGrabPose.current ??= { - middle: { metacarpal: middleMetacarpalPosition.current, tip: middleFingerPosition.current }, - pinky: { metacarpal: pinkyMetacarpalPosition.current, tip: pinkyFingerPosition.current }, - ring: { metacarpal: ringMetacarpalPosition.current, tip: ringFingerPosition.current }, - wrist: wristPosition.current, - } - - const grabbed = advancePalmGrab( - palmGrabState.current, - tracked ? palmGrabPose.current : null, - delta, - ) - if (tracked) { - palmGrabPosition.current - .copy(middleMetacarpalPosition.current) - .add(ringMetacarpalPosition.current) - .multiplyScalar(0.5) - } - updateGodScaleHandState(handedness, grabbed, tracked, palmGrabPosition.current) - }) - - return ( - <> - - - - - - - - - ) -} diff --git a/packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts b/packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts deleted file mode 100644 index 54c4b3f669..0000000000 --- a/packages/viewer/src/xr/god-mode/lib/palm-grab.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { advancePalmGrab, PALM_GRAB_HOLD_SECONDS } from './palm-grab' - -const wrist = { x: 0, y: 0, z: 0 } -const closedPose = { - wrist, - middle: { metacarpal: { x: 1, y: 0, z: 0 }, tip: { x: 1.4, y: 0, z: 0 } }, - ring: { metacarpal: { x: 0, y: 1, z: 0 }, tip: { x: 0, y: 1.4, z: 0 } }, - pinky: { metacarpal: { x: -1, y: 0, z: 0 }, tip: { x: -1.4, y: 0, z: 0 } }, -} - -describe('palm grab', () => { - test('activates only after all three fingers remain curled for the hold time', () => { - const state = { grabbed: false, elapsed: 0 } - - expect(advancePalmGrab(state, closedPose, PALM_GRAB_HOLD_SECONDS - 0.01)).toBe(false) - expect(advancePalmGrab(state, closedPose, 0.01)).toBe(true) - }) - - test('stays grabbed through tracking noise and releases when a finger opens', () => { - const state = { grabbed: true, elapsed: PALM_GRAB_HOLD_SECONDS } - const partlyOpenPose = { - ...closedPose, - middle: { ...closedPose.middle, tip: { x: 2.8, y: 0, z: 0 } }, - } - const openPose = { - ...closedPose, - middle: { ...closedPose.middle, tip: { x: 3.4, y: 0, z: 0 } }, - } - - expect(advancePalmGrab(state, partlyOpenPose, 0.016)).toBe(true) - expect(advancePalmGrab(state, openPose, 0.016)).toBe(false) - }) -}) diff --git a/packages/viewer/src/xr/god-mode/lib/palm-grab.ts b/packages/viewer/src/xr/god-mode/lib/palm-grab.ts deleted file mode 100644 index 51c4a8bbc9..0000000000 --- a/packages/viewer/src/xr/god-mode/lib/palm-grab.ts +++ /dev/null @@ -1,74 +0,0 @@ -export const PALM_GRAB_HOLD_SECONDS = 0.12 -export const PALM_GRAB_TRIGGER_EXTENSION = 2.55 -export const PALM_GRAB_RELEASE_EXTENSION = 3.2 - -type Point = { x: number; y: number; z: number } - -type FingerPose = { - metacarpal: Point - tip: Point -} - -export type PalmGrabPose = { - middle: FingerPose - pinky: FingerPose - ring: FingerPose - wrist: Point -} - -export type PalmGrabState = { - elapsed: number - grabbed: boolean -} - -const FINGERS = ['middle', 'ring', 'pinky'] as const - -function distance(first: Point, second: Point) { - return Math.hypot(first.x - second.x, first.y - second.y, first.z - second.z) -} - -function resolveFingerExtension(wrist: Point, finger: FingerPose) { - const palmLength = distance(wrist, finger.metacarpal) - if (!Number.isFinite(palmLength) || palmLength <= 1e-6) return Number.POSITIVE_INFINITY - return distance(wrist, finger.tip) / palmLength -} - -export function advancePalmGrab( - state: PalmGrabState, - pose: PalmGrabPose | null, - deltaSeconds: number, - enabled = true, -) { - const extensions = - pose && FINGERS.map((finger) => resolveFingerExtension(pose.wrist, pose[finger])) - if (state.grabbed) { - const held = - enabled && - extensions?.every( - (extension) => Number.isFinite(extension) && extension < PALM_GRAB_RELEASE_EXTENSION, - ) - if (held) return true - - state.grabbed = false - state.elapsed = 0 - return false - } - - const curled = - enabled && - extensions?.every( - (extension) => Number.isFinite(extension) && extension <= PALM_GRAB_TRIGGER_EXTENSION, - ) - - if (!curled) { - state.grabbed = false - state.elapsed = 0 - return false - } - - state.elapsed += Math.max(0, Number.isFinite(deltaSeconds) ? deltaSeconds : 0) - if (state.elapsed < PALM_GRAB_HOLD_SECONDS) return false - - state.grabbed = true - return true -} diff --git a/packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts b/packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts deleted file mode 100644 index 869d4a4741..0000000000 --- a/packages/viewer/src/xr/god-mode/lib/scale-interaction.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { Object3D, Vector3 } from 'three' -import { - applyGodScaleGesture, - resetGodScaleRoot, - resolveGodScalePan, - resolveGodScaleTransform, -} from './scale-interaction' - -describe('God-scale live gesture lifecycle', () => { - test('pans the scene root with a single grip', () => { - const root = new Object3D() - const gesture = { mode: null } - const leftPosition = new Vector3(0, 1, 0) - const rightPosition = new Vector3() - - applyGodScaleGesture({ root, gesture, mode: 'left', leftPosition, rightPosition }) - leftPosition.set(0.1, 1, 0) - applyGodScaleGesture({ root, gesture, mode: 'left', leftPosition, rightPosition }) - - expect(root.position.x).toBeCloseTo(0.3) - }) - - test('scales and rotates the scene root with two grips', () => { - const root = new Object3D() - const gesture = { mode: null } - const leftPosition = new Vector3(-1, 0, 0) - const rightPosition = new Vector3(1, 0, 0) - - applyGodScaleGesture({ root, gesture, mode: 'two', leftPosition, rightPosition }) - leftPosition.set(0, 0, -2) - rightPosition.set(0, 0, 2) - applyGodScaleGesture({ root, gesture, mode: 'two', leftPosition, rightPosition }) - - expect(root.scale.x).toBe(2) - expect(root.rotation.y).toBeCloseTo(-Math.PI / 2) - }) - - test('resets the scene root and cancels the active gesture', () => { - const root = new Object3D() - const gesture = { mode: 'two' as const } - root.position.set(4, -2, 7) - root.rotation.set(0.2, 1.1, -0.4) - root.scale.setScalar(3) - - resetGodScaleRoot(root, gesture) - - expect(root.position.toArray()).toEqual([0, 0, 0]) - expect(root.rotation.toArray().slice(0, 3)).toEqual([0, 0, 0]) - expect(root.scale.toArray()).toEqual([1, 1, 1]) - expect(gesture.mode).toBeNull() - }) -}) - -describe('God-scale transform math', () => { - test('pans by the movement of one grip', () => { - const result = resolveGodScalePan( - new Vector3(2, 0, -1), - new Vector3(0, 1, 0), - new Vector3(0.5, 1.25, -0.25), - ) - expect(result.toArray()).toEqual([2.5, 0.25, -1.25]) - }) - - test('scales around the two-grip midpoint and follows midpoint movement', () => { - const result = resolveGodScaleTransform({ - rootPosition: new Vector3(), - rootScale: 1, - startLeft: new Vector3(-1, 0, 0), - startRight: new Vector3(1, 0, 0), - currentLeft: new Vector3(-1.5, 0, 1), - currentRight: new Vector3(2.5, 0, 1), - }) - - expect(result.scale).toBe(2) - expect(result.position.toArray()).toEqual([0.5, 0, 1]) - expect(result.rotationY).toBe(0) - }) - - test('keeps scaling above the original maximum', () => { - const result = resolveGodScaleTransform({ - rootPosition: new Vector3(1, 0, 0), - rootScale: 10, - startLeft: new Vector3(-1, 0, 0), - startRight: new Vector3(1, 0, 0), - currentLeft: new Vector3(-4, 0, 0), - currentRight: new Vector3(4, 0, 0), - }) - - expect(result.scale).toBe(40) - expect(result.position.toArray()).toEqual([4, 0, 0]) - }) - - test('stops at the minimum scale while keeping the midpoint anchored', () => { - const result = resolveGodScaleTransform({ - rootPosition: new Vector3(2, 0, 0), - rootScale: 0.1, - startLeft: new Vector3(-1, 0, 0), - startRight: new Vector3(1, 0, 0), - currentLeft: new Vector3(-0.1, 0, 0), - currentRight: new Vector3(0.1, 0, 0), - }) - - expect(result.scale).toBe(0.05) - expect(result.position.toArray()).toEqual([1, 0, 0]) - }) -}) diff --git a/packages/viewer/src/xr/god-mode/lib/scale-interaction.ts b/packages/viewer/src/xr/god-mode/lib/scale-interaction.ts deleted file mode 100644 index d8d1934fa4..0000000000 --- a/packages/viewer/src/xr/god-mode/lib/scale-interaction.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { type Object3D, Vector3 } from 'three' - -const Y_AXIS = new Vector3(0, 1, 0) -const GOD_SCALE_MIN = 0.05 - -export type GodScaleGestureMode = 'left' | 'right' | 'two' - -export type GodScaleGesture = { - mode: GodScaleGestureMode | null - rootPosition?: Vector3 - rootRotationY?: number - rootScale?: number - startGrip?: Vector3 - startLeft?: Vector3 - startRight?: Vector3 -} - -export function isGodScaleInteractionEnabled(gestureMode: GodScaleGestureMode | null) { - return gestureMode != null -} - -export function resetGodScaleRoot(root: Object3D, gesture: GodScaleGesture) { - root.position.set(0, 0, 0) - root.rotation.set(0, 0, 0) - root.scale.setScalar(1) - gesture.mode = null -} - -export function resolveGodScalePan( - rootPosition: Vector3, - startGrip: Vector3, - currentGrip: Vector3, - target = new Vector3(), - sensitivity = 1, -) { - return target.copy(currentGrip).sub(startGrip).multiplyScalar(sensitivity).add(rootPosition) -} - -export function resolveGodScaleTransform({ - rootPosition, - rootScale, - startLeft, - startRight, - currentLeft, - currentRight, - targetPosition = new Vector3(), - translationSensitivity = 1, - scaleSensitivity = 1, - scaleAroundMidpoint = true, -}: { - rootPosition: Vector3 - rootScale: number - startLeft: Vector3 - startRight: Vector3 - currentLeft: Vector3 - currentRight: Vector3 - targetPosition?: Vector3 - translationSensitivity?: number - scaleSensitivity?: number - scaleAroundMidpoint?: boolean -}) { - const startMidpoint = startLeft.clone().add(startRight).multiplyScalar(0.5) - const currentMidpoint = currentLeft.clone().add(currentRight).multiplyScalar(0.5) - const startVector = startRight.clone().sub(startLeft) - const currentVector = currentRight.clone().sub(currentLeft) - const startDistance = startVector.length() - const currentDistance = currentVector.length() - const rawScaleRatio = startDistance > 1e-6 ? currentDistance / startDistance : 1 - const scaleRatio = rawScaleRatio ** scaleSensitivity - const scale = Math.max(GOD_SCALE_MIN, rootScale * scaleRatio) - const effectiveScaleRatio = scale / rootScale - const rotationY = - Math.atan2(startVector.z, startVector.x) - Math.atan2(currentVector.z, currentVector.x) - const translatedMidpoint = currentMidpoint - .sub(startMidpoint) - .multiplyScalar(translationSensitivity) - - if (scaleAroundMidpoint) { - targetPosition - .copy(rootPosition) - .sub(startMidpoint) - .multiplyScalar(effectiveScaleRatio) - .applyAxisAngle(Y_AXIS, rotationY) - .add(translatedMidpoint.add(startMidpoint)) - } else { - targetPosition.copy(rootPosition).add(translatedMidpoint) - } - - return { position: targetPosition, rotationY, scale } -} - -export function applyGodScaleGesture({ - root, - gesture, - mode, - leftPosition, - rightPosition, - targetPosition = new Vector3(), - translationSensitivity = 3, - scaleSensitivity = 1, - scaleAroundMidpoint = false, -}: { - root: Object3D - gesture: GodScaleGesture - mode: GodScaleGestureMode - leftPosition: Vector3 - rightPosition: Vector3 - targetPosition?: Vector3 - translationSensitivity?: number - scaleSensitivity?: number - scaleAroundMidpoint?: boolean -}) { - if (gesture.mode !== mode) { - gesture.mode = mode - gesture.rootPosition = root.position.clone() - gesture.rootScale = root.scale.x - gesture.rootRotationY = root.rotation.y - if (mode === 'two') { - gesture.startLeft = leftPosition.clone() - gesture.startRight = rightPosition.clone() - } else { - gesture.startGrip = (mode === 'left' ? leftPosition : rightPosition).clone() - } - } - - if (mode === 'two') { - const result = resolveGodScaleTransform({ - rootPosition: gesture.rootPosition!, - rootScale: gesture.rootScale!, - startLeft: gesture.startLeft!, - startRight: gesture.startRight!, - currentLeft: leftPosition, - currentRight: rightPosition, - targetPosition, - translationSensitivity, - scaleSensitivity, - scaleAroundMidpoint, - }) - root.position.copy(result.position) - root.scale.setScalar(result.scale) - root.rotation.y = gesture.rootRotationY! + result.rotationY - return - } - - const currentGrip = mode === 'left' ? leftPosition : rightPosition - root.position.copy( - resolveGodScalePan( - gesture.rootPosition!, - gesture.startGrip!, - currentGrip, - targetPosition, - translationSensitivity, - ), - ) -} diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts b/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts deleted file mode 100644 index fcb906b779..0000000000 --- a/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { beforeEach, describe, expect, test } from 'bun:test' -import { Vector3 } from 'three' -import { - clearGodScaleHandState, - getGodScaleHandState, - updateGodScaleHandState, -} from './god-mode-hand-store' - -describe('God-scale hand state', () => { - beforeEach(() => clearGodScaleHandState('left')) - - test('publishes a palm gesture as the same grab used by controllers', () => { - updateGodScaleHandState('left', true, true, new Vector3(1, 2, 3)) - - expect(getGodScaleHandState('left')).toMatchObject({ grabbed: true, tracked: true }) - expect(getGodScaleHandState('left').position.toArray()).toEqual([1, 2, 3]) - }) -}) diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts b/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts deleted file mode 100644 index 635f3eb7e9..0000000000 --- a/packages/viewer/src/xr/god-mode/store/god-mode-hand-store.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Vector3 } from 'three' - -type GodModeHandedness = 'left' | 'right' - -export type GodModeHandState = { - grabbed: boolean - position: Vector3 - tracked: boolean -} - -function createHandState(): GodModeHandState { - return { grabbed: false, position: new Vector3(), tracked: false } -} - -const hands: Record = { - left: createHandState(), - right: createHandState(), -} - -function isGodModeHandedness(handedness: XRHandedness): handedness is GodModeHandedness { - return handedness === 'left' || handedness === 'right' -} - -export function updateGodScaleHandState( - handedness: XRHandedness, - grabbed: boolean, - tracked: boolean, - position?: Vector3, -) { - if (!isGodModeHandedness(handedness)) return - const hand = hands[handedness] - hand.grabbed = grabbed - hand.tracked = tracked - if (tracked && position) hand.position.copy(position) -} - -export function getGodScaleHandState(handedness: GodModeHandedness) { - return hands[handedness] -} - -export function clearGodScaleHandState(handedness: XRHandedness) { - updateGodScaleHandState(handedness, false, false) -} - -export function clearGodScaleHandStates() { - clearGodScaleHandState('left') - clearGodScaleHandState('right') -} diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts b/packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts deleted file mode 100644 index 669e6342d2..0000000000 --- a/packages/viewer/src/xr/god-mode/store/god-mode-view-store.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { beforeEach, describe, expect, test } from 'bun:test' -import { requestGodScaleReset, useGodScaleView } from './god-mode-view-store' - -describe('God-scale reset requests', () => { - beforeEach(() => useGodScaleView.setState({ resetRequest: 0 })) - - test('publishes every reset request', () => { - requestGodScaleReset() - requestGodScaleReset() - - expect(useGodScaleView.getState().resetRequest).toBe(2) - }) -}) diff --git a/packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts b/packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts deleted file mode 100644 index 869b0442b3..0000000000 --- a/packages/viewer/src/xr/god-mode/store/god-mode-view-store.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { create } from 'zustand' - -type GodModeViewState = { - requestReset(): void - resetRequest: number -} - -export const useGodScaleView = create((set) => ({ - resetRequest: 0, - requestReset: () => set((state) => ({ resetRequest: state.resetRequest + 1 })), -})) - -export function requestGodScaleReset() { - useGodScaleView.getState().requestReset() -} diff --git a/packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx b/packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx deleted file mode 100644 index 64983d225f..0000000000 --- a/packages/viewer/src/xr/god-mode/ui/god-mode-controls.tsx +++ /dev/null @@ -1,172 +0,0 @@ -'use client' - -import { useFrame } from '@react-three/fiber' -import { useXR, useXRInputSourceState, type XRControllerState } from '@react-three/xr' -import { type RefObject, useEffect, useRef } from 'react' -import { type Object3D, Vector3 } from 'three' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' -import { GOD_ORIGIN_POSITION, GOD_ORIGIN_ROTATION } from '../constants/god-mode-constants' -import { - applyGodScaleGesture, - type GodScaleGesture, - type GodScaleGestureMode, - isGodScaleInteractionEnabled, - resetGodScaleRoot, -} from '../lib/scale-interaction' -import { getGodScaleHandState } from '../store/god-mode-hand-store' -import { useGodScaleView } from '../store/god-mode-view-store' - -function isControllerGrabPressed(state: XRControllerState | undefined) { - if (state?.gamepad?.['xr-standard-squeeze']?.state === 'pressed') return true - return state?.inputSource.gamepad?.buttons[1]?.pressed === true -} - -function getGripPosition( - state: XRControllerState | undefined, - frame: XRFrame | undefined, - referenceSpace: XRReferenceSpace | undefined, - origin: Object3D | undefined, - target: Vector3, -) { - if (frame && referenceSpace && state?.inputSource.gripSpace) { - const pose = frame.getPose(state.inputSource.gripSpace, referenceSpace) - if (pose) { - target.set(pose.transform.position.x, pose.transform.position.y, pose.transform.position.z) - origin?.localToWorld(target) - return true - } - } - - if (!state?.object) return false - const gripSpaceObject = state.object.parent ?? state.object - gripSpaceObject.updateWorldMatrix(true, false) - gripSpaceObject.getWorldPosition(target) - return true -} - -function resetGestureOnRequest( - rootRef: RefObject, - gesture: RefObject, - resetRequest: number, - handledResetRequest: RefObject, -) { - if (handledResetRequest.current === resetRequest || !rootRef.current) return - resetGodScaleRoot(rootRef.current, gesture.current) - handledResetRequest.current = resetRequest -} - -function GodScaleController({ sceneRootRef }: { sceneRootRef: RefObject }) { - const leftController = useXRInputSourceState('controller', 'left') - const rightController = useXRInputSourceState('controller', 'right') - const referenceSpace = useXR((state) => state.originReferenceSpace) - const origin = useXR((state) => state.origin) - const resetRequest = useGodScaleView((state) => state.resetRequest) - const playerMode = useXRPlayerMode((state) => state.mode) - const gesture = useRef({ mode: null }) - const handledResetRequest = useRef(resetRequest) - const leftPosition = useRef(new Vector3()) - const rightPosition = useRef(new Vector3()) - const nextPosition = useRef(new Vector3()) - - useEffect(() => { - resetGestureOnRequest(sceneRootRef, gesture, resetRequest, handledResetRequest) - }, [resetRequest, sceneRootRef]) - - useFrame((_, __, frame) => { - const root = sceneRootRef.current - const leftPressed = isControllerGrabPressed(leftController) - const rightPressed = isControllerGrabPressed(rightController) - const mode: GodScaleGestureMode | null = - leftPressed && rightPressed ? 'two' : leftPressed ? 'left' : rightPressed ? 'right' : null - - if (!root || playerMode !== XR_PLAYER_MODES.GOD || !isGodScaleInteractionEnabled(mode)) { - gesture.current.mode = null - return - } - - const hasLeftPosition = - !leftPressed || - getGripPosition(leftController, frame, referenceSpace, origin, leftPosition.current) - const hasRightPosition = - !rightPressed || - getGripPosition(rightController, frame, referenceSpace, origin, rightPosition.current) - if (!hasLeftPosition || !hasRightPosition) { - gesture.current.mode = null - return - } - - applyGodScaleGesture({ - gesture: gesture.current, - leftPosition: leftPosition.current, - mode, - rightPosition: rightPosition.current, - root, - targetPosition: nextPosition.current, - }) - }) - - return null -} - -function GodScaleHandController({ sceneRootRef }: { sceneRootRef: RefObject }) { - const resetRequest = useGodScaleView((state) => state.resetRequest) - const playerMode = useXRPlayerMode((state) => state.mode) - const gesture = useRef({ mode: null }) - const handledResetRequest = useRef(resetRequest) - const nextPosition = useRef(new Vector3()) - - useEffect(() => { - resetGestureOnRequest(sceneRootRef, gesture, resetRequest, handledResetRequest) - }, [resetRequest, sceneRootRef]) - - useFrame(() => { - const root = sceneRootRef.current - const leftHand = getGodScaleHandState('left') - const rightHand = getGodScaleHandState('right') - const mode: GodScaleGestureMode | null = - leftHand.grabbed && rightHand.grabbed - ? 'two' - : leftHand.grabbed - ? 'left' - : rightHand.grabbed - ? 'right' - : null - - if (!root || playerMode !== XR_PLAYER_MODES.GOD || !isGodScaleInteractionEnabled(mode)) { - gesture.current.mode = null - return - } - - applyGodScaleGesture({ - gesture: gesture.current, - leftPosition: leftHand.position, - mode, - rightPosition: rightHand.position, - root, - targetPosition: nextPosition.current, - }) - }) - - return null -} - -export function GodModeControls({ sceneRootRef }: { sceneRootRef: RefObject }) { - const origin = useXR((state) => state.origin) - const resetRequest = useGodScaleView((state) => state.resetRequest) - const handledResetRequest = useRef(resetRequest) - - useEffect(() => { - if (handledResetRequest.current === resetRequest || !sceneRootRef.current || !origin) return - resetGodScaleRoot(sceneRootRef.current, { mode: null }) - origin.position.copy(GOD_ORIGIN_POSITION) - origin.rotation.copy(GOD_ORIGIN_ROTATION) - handledResetRequest.current = resetRequest - }, [origin, resetRequest, sceneRootRef]) - - return ( - - - - - ) -} diff --git a/packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts b/packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts deleted file mode 100644 index afe3cf8b8f..0000000000 --- a/packages/viewer/src/xr/human-mode/constants/human-mode-constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const HAND_DEAD_ZONE = 0.015 -export const HAND_ZONE_RADIUS = 0.12 -export const HAND_SPEED = 1.5 -export const HAND_TURN_SPEED = Math.PI / 2 -export const HAND_PINCH_TOUCH_DISTANCE = 0.03 -export const HAND_PINCH_RELEASE_DISTANCE = 0.045 -export const SNAP_TURN_ANGLE = Math.PI / 6 -export const SNAP_TURN_THRESHOLD = 0.65 diff --git a/packages/viewer/src/xr/human-mode/index.ts b/packages/viewer/src/xr/human-mode/index.ts deleted file mode 100644 index 8886044d0e..0000000000 --- a/packages/viewer/src/xr/human-mode/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type LocomotionSettings, - useLocomotionSettings, -} from './store/locomotion-settings' -export { HumanModeControls } from './ui/human-mode-controls' diff --git a/packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx b/packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx deleted file mode 100644 index b5f090de4c..0000000000 --- a/packages/viewer/src/xr/human-mode/input/controller-locomotion.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client' - -import { useFrame, useThree } from '@react-three/fiber' -import { useXR, useXRInputSourceState } from '@react-three/xr' -import { useRef } from 'react' -import { Vector3 } from 'three' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' -import { pulseInputSource } from '../lib/haptics' -import { - getCameraRelativeRight, - getControllerThumbstickAxis, - normalizeMovementVector, - resolveLocomotionDelta, - setArtificialMovementSpeed, -} from '../lib/locomotion' -import { rotateOriginAroundCamera, translateOrigin } from '../lib/origin-navigation' -import { resolveSnapTurnDirection, SNAP_TURN_ANGLE, shouldSnapTurn } from '../lib/snap-turn' -import { resolveHumanCollisionTranslation } from '../store/collision-store' -import { useLocomotionSettings } from '../store/locomotion-settings' - -export function ControllerLocomotion() { - const leftController = useXRInputSourceState('controller', 'left') - const rightController = useXRInputSourceState('controller', 'right') - const origin = useXR((state) => state.origin) - const camera = useThree((state) => state.camera) - const mode = useXRPlayerMode((state) => state.mode) - const moveSpeed = useLocomotionSettings((state) => state.moveSpeed) - const turnSensitivity = useLocomotionSettings((state) => state.turnSensitivity) - const direction = useRef(new Vector3()) - const right = useRef(new Vector3()) - const movement = useRef(new Vector3()) - const resolvedMovement = useRef(new Vector3()) - const playerPosition = useRef(new Vector3()) - const resolvedPlayerPosition = useRef(new Vector3()) - const previousTurnDirection = useRef(0) - const cameraBeforeTurn = useRef(new Vector3()) - const cameraAfterTurn = useRef(new Vector3()) - - useFrame((_, delta) => { - const x = getControllerThumbstickAxis(leftController, 0) - const y = getControllerThumbstickAxis(leftController, 1) - const rightX = getControllerThumbstickAxis(rightController, 0) - if (mode !== XR_PLAYER_MODES.HUMAN || !origin) { - setArtificialMovementSpeed(0) - previousTurnDirection.current = 0 - return - } - - const locomotionDelta = resolveLocomotionDelta(delta) - setArtificialMovementSpeed(Math.min(1, Math.hypot(x, y)) * moveSpeed) - if (Math.max(Math.abs(x), Math.abs(y)) > 0.1) { - camera.getWorldDirection(direction.current) - direction.current.y = 0 - direction.current.normalize() - getCameraRelativeRight(direction.current, right.current) - const normalized = normalizeMovementVector(x, y) - movement.current.copy(right.current).multiplyScalar(normalized.x) - movement.current.addScaledVector(direction.current, -normalized.z) - movement.current.multiplyScalar(moveSpeed * locomotionDelta) - camera.getWorldPosition(playerPosition.current) - resolveHumanCollisionTranslation( - playerPosition.current, - movement.current, - resolvedMovement.current, - ) - translateOrigin( - origin, - resolvedMovement.current, - playerPosition.current, - resolvedPlayerPosition.current, - ) - } - - const turnDirection = resolveSnapTurnDirection(rightX) - if (shouldSnapTurn(previousTurnDirection.current, turnDirection)) { - rotateOriginAroundCamera( - origin, - camera, - -turnDirection * SNAP_TURN_ANGLE * turnSensitivity, - cameraBeforeTurn.current, - cameraAfterTurn.current, - ) - pulseInputSource(rightController?.inputSource, 0.25, 35) - } - previousTurnDirection.current = turnDirection - }) - - return null -} diff --git a/packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx b/packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx deleted file mode 100644 index 821930b536..0000000000 --- a/packages/viewer/src/xr/human-mode/input/hand-locomotion.tsx +++ /dev/null @@ -1,210 +0,0 @@ -'use client' - -import { useFrame, useThree } from '@react-three/fiber' -import { useXR, useXRInputSourceStateContext, XRSpace } from '@react-three/xr' -import { useEffect, useRef } from 'react' -import { type Object3D, Vector3 } from 'three' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' -import { - getHandLocomotionZoneCenter, - isInsideHandLocomotionZone, - normalizeHandLocomotionOffset, - resolveHandPinching, - resolveHandTurnDelta, -} from '../lib/hand-locomotion' -import { isPalmFacingUp } from '../lib/hand-pose' -import { pulseInputSource } from '../lib/haptics' -import { - getCameraRelativeRight, - normalizeMovementVector, - resolveLocomotionDelta, - setArtificialMovementSpeed, -} from '../lib/locomotion' -import { rotateOriginAroundCamera, translateOrigin } from '../lib/origin-navigation' -import { resolveHumanCollisionTranslation } from '../store/collision-store' -import { - hideHandLocomotionJoystick, - setHandLocomotionState, - showHandLocomotionJoystick, -} from '../store/hand-locomotion-joystick' -import { useLocomotionSettings } from '../store/locomotion-settings' - -const LOCOMOTION_HAND = 'left' -const TURN_HAND = 'right' - -export function HumanModeHandControls() { - const state = useXRInputSourceStateContext('hand') - const origin = useXR((xrState) => xrState.origin) - const camera = useThree((threeState) => threeState.camera) - const mode = useXRPlayerMode((playerState) => playerState.mode) - const moveSpeed = useLocomotionSettings((settings) => settings.moveSpeed) - const turnSensitivity = useLocomotionSettings((settings) => settings.turnSensitivity) - const indexTip = useRef(null) - const thumbTip = useRef(null) - const middleTip = useRef(null) - const wrist = useRef(null) - const indexMetacarpal = useRef(null) - const pinkyMetacarpal = useRef(null) - const indexPosition = useRef(new Vector3()) - const thumbPosition = useRef(new Vector3()) - const middlePosition = useRef(new Vector3()) - const wristPosition = useRef(new Vector3()) - const indexMetacarpalPosition = useRef(new Vector3()) - const pinkyMetacarpalPosition = useRef(new Vector3()) - const localHandPosition = useRef(new Vector3()) - const cameraLocalPosition = useRef(new Vector3()) - const zoneCenter = useRef(new Vector3()) - const pinchOrigin = useRef(new Vector3()) - const direction = useRef(new Vector3()) - const right = useRef(new Vector3()) - const movement = useRef(new Vector3()) - const resolvedMovement = useRef(new Vector3()) - const playerPosition = useRef(new Vector3()) - const resolvedPlayerPosition = useRef(new Vector3()) - const cameraBeforeTurn = useRef(new Vector3()) - const cameraAfterTurn = useRef(new Vector3()) - const pinching = useRef(false) - const active = useRef(false) - const controlOriginSet = useRef(false) - const controlState = useRef<'idle' | 'ready'>('idle') - const handedness = state.inputSource.handedness - - useEffect( - () => () => { - if (handedness === 'left' || handedness === 'right') { - hideHandLocomotionJoystick(handedness) - } - if (handedness === LOCOMOTION_HAND) setArtificialMovementSpeed(0) - }, - [handedness], - ) - - useFrame((_, delta) => { - if (handedness !== 'left' && handedness !== 'right') return - const tracked = Boolean( - indexTip.current?.visible && - thumbTip.current?.visible && - middleTip.current?.visible && - wrist.current?.visible && - indexMetacarpal.current?.visible && - pinkyMetacarpal.current?.visible, - ) - if (tracked) { - indexTip.current!.getWorldPosition(indexPosition.current) - thumbTip.current!.getWorldPosition(thumbPosition.current) - middleTip.current!.getWorldPosition(middlePosition.current) - wrist.current!.getWorldPosition(wristPosition.current) - indexMetacarpal.current!.getWorldPosition(indexMetacarpalPosition.current) - pinkyMetacarpal.current!.getWorldPosition(pinkyMetacarpalPosition.current) - } - const nextPinching = resolveHandPinching( - pinching.current, - tracked ? thumbPosition.current.distanceTo(middlePosition.current) : Number.POSITIVE_INFINITY, - ) - pinching.current = nextPinching - - if (mode !== XR_PLAYER_MODES.HUMAN || !origin || !tracked) { - active.current = false - controlOriginSet.current = false - hideHandLocomotionJoystick(handedness) - if (handedness === LOCOMOTION_HAND) setArtificialMovementSpeed(0) - return - } - - const palmUp = isPalmFacingUp( - wristPosition.current, - indexMetacarpalPosition.current, - pinkyMetacarpalPosition.current, - handedness, - ) - localHandPosition.current.copy(indexPosition.current) - origin.worldToLocal(localHandPosition.current) - camera.getWorldPosition(cameraLocalPosition.current) - origin.worldToLocal(cameraLocalPosition.current) - getHandLocomotionZoneCenter(handedness, zoneCenter.current, cameraLocalPosition.current) - const insideZone = - palmUp && - isInsideHandLocomotionZone(localHandPosition.current, handedness, cameraLocalPosition.current) - const nextState = insideZone ? 'ready' : 'idle' - if (controlState.current !== nextState && !active.current) { - controlState.current = nextState - setHandLocomotionState(handedness, nextState, zoneCenter.current) - } - - if (!pinching.current || !palmUp) { - active.current = false - controlOriginSet.current = false - hideHandLocomotionJoystick(handedness) - if (handedness === LOCOMOTION_HAND) setArtificialMovementSpeed(0) - return - } - if (!active.current && insideZone) { - active.current = true - pulseInputSource(state.inputSource, 0.2, 35) - } - if (!active.current) return - if (!controlOriginSet.current) { - pinchOrigin.current.copy(zoneCenter.current) - controlOriginSet.current = true - showHandLocomotionJoystick(pinchOrigin.current, handedness) - return - } - - const locomotionDelta = resolveLocomotionDelta(delta) - if (handedness === TURN_HAND) { - const turnDelta = - resolveHandTurnDelta(localHandPosition.current.x - pinchOrigin.current.x, locomotionDelta) * - turnSensitivity - if (turnDelta !== 0) { - rotateOriginAroundCamera( - origin, - camera, - turnDelta, - cameraBeforeTurn.current, - cameraAfterTurn.current, - ) - } - return - } - - const inputX = normalizeHandLocomotionOffset( - localHandPosition.current.x - pinchOrigin.current.x, - ) - const inputZ = normalizeHandLocomotionOffset( - localHandPosition.current.z - pinchOrigin.current.z, - ) - setArtificialMovementSpeed(Math.min(1, Math.hypot(inputX, inputZ)) * moveSpeed) - if (inputX === 0 && inputZ === 0) return - camera.getWorldDirection(direction.current) - direction.current.y = 0 - direction.current.normalize() - getCameraRelativeRight(direction.current, right.current) - const normalized = normalizeMovementVector(inputX, inputZ) - movement.current.copy(right.current).multiplyScalar(normalized.x) - movement.current.addScaledVector(direction.current, -normalized.z) - movement.current.multiplyScalar(moveSpeed * locomotionDelta) - camera.getWorldPosition(playerPosition.current) - resolveHumanCollisionTranslation( - playerPosition.current, - movement.current, - resolvedMovement.current, - ) - translateOrigin( - origin, - resolvedMovement.current, - playerPosition.current, - resolvedPlayerPosition.current, - ) - }) - - return ( - <> - - - - - - - - ) -} diff --git a/packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx b/packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx deleted file mode 100644 index ebaf6d07d9..0000000000 --- a/packages/viewer/src/xr/human-mode/input/human-collision-rig.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client' - -import { useFrame, useThree } from '@react-three/fiber' -import { useXR } from '@react-three/xr' -import { type RefObject, useEffect, useRef } from 'react' -import { type Mesh, type Object3D, Quaternion, Vector3 } from 'three' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' -import { resolveRoomScaleOriginCorrection } from '../lib/capsule-collision' -import { setActiveHumanColliders } from '../store/collision-store' - -function collectColliders(root: Object3D) { - const colliders: Mesh[] = [] - root.traverse((object) => { - const mesh = object as Mesh - if ( - mesh.isMesh && - mesh.visible && - mesh.geometry?.boundsTree && - mesh.userData.excludeFromBvh !== true - ) { - colliders.push(mesh) - } - }) - return colliders -} - -export function HumanCollisionRig({ sceneRootRef }: { sceneRootRef: RefObject }) { - const camera = useThree((state) => state.camera) - const origin = useXR((state) => state.origin) - const mode = useXRPlayerMode((state) => state.mode) - const colliders = useRef([]) - const collected = useRef(false) - const hasViewerPose = useRef(false) - const previousLocalPosition = useRef(new Vector3()) - const currentLocalPosition = useRef(new Vector3()) - const currentWorldPosition = useRef(new Vector3()) - const previousWorldPosition = useRef(new Vector3()) - const physicalMovement = useRef(new Vector3()) - const originWorldRotation = useRef(new Quaternion()) - const originCorrection = useRef(new Vector3()) - - useEffect(() => { - if (mode !== XR_PLAYER_MODES.HUMAN) { - colliders.current = [] - collected.current = false - hasViewerPose.current = false - setActiveHumanColliders([]) - } - }, [mode]) - - useFrame(() => { - if (mode !== XR_PLAYER_MODES.HUMAN || !origin || !sceneRootRef.current) return - if (!collected.current) { - colliders.current = collectColliders(sceneRootRef.current) - if (colliders.current.length > 0) { - collected.current = true - setActiveHumanColliders(colliders.current) - } - } - - camera.getWorldPosition(currentWorldPosition.current) - currentLocalPosition.current.copy(currentWorldPosition.current) - origin.worldToLocal(currentLocalPosition.current) - if (!hasViewerPose.current) { - previousLocalPosition.current.copy(currentLocalPosition.current) - previousWorldPosition.current.copy(currentWorldPosition.current) - hasViewerPose.current = true - return - } - - physicalMovement.current.copy(currentLocalPosition.current).sub(previousLocalPosition.current) - origin.getWorldQuaternion(originWorldRotation.current) - physicalMovement.current.applyQuaternion(originWorldRotation.current) - previousWorldPosition.current.copy(currentWorldPosition.current).sub(physicalMovement.current) - resolveRoomScaleOriginCorrection( - colliders.current, - previousWorldPosition.current, - currentWorldPosition.current, - originCorrection.current, - ) - origin.position.add(originCorrection.current) - previousLocalPosition.current.copy(currentLocalPosition.current) - }) - - useEffect(() => () => setActiveHumanColliders([]), []) - return null -} diff --git a/packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts b/packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts deleted file mode 100644 index 2c0089e4fe..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/capsule-collision.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { BoxGeometry, Mesh, Vector3 } from 'three' -import { computeBoundsTree } from 'three-mesh-bvh' -import { resolveCapsuleTranslation, resolveRoomScaleOriginCorrection } from './capsule-collision' - -function createWallCollider() { - const geometry = new BoxGeometry(4, 3, 0.1) - ;(geometry as unknown as { computeBoundsTree: typeof computeBoundsTree }).computeBoundsTree = - computeBoundsTree - ;(geometry as unknown as { computeBoundsTree(): void }).computeBoundsTree() - const wall = new Mesh(geometry) - wall.position.y = 1.5 - wall.updateWorldMatrix(true, false) - return wall -} - -describe('Human capsule collision', () => { - test('stops a large movement before tunneling through a rendered wall', () => { - const wall = createWallCollider() - const movement = resolveCapsuleTranslation( - [wall], - new Vector3(0, 1.65, -1), - new Vector3(0, 0, 2), - new Vector3(), - ) - - expect(movement.z).toBeCloseTo(0.7, 2) - wall.geometry.dispose() - }) - - test('corrects room-scale walking that crosses a rendered wall', () => { - const wall = createWallCollider() - const correction = resolveRoomScaleOriginCorrection( - [wall], - new Vector3(0, 1.65, -1), - new Vector3(0, 1.65, 1), - new Vector3(), - ) - - expect(correction.z).toBeCloseTo(-1.3, 2) - wall.geometry.dispose() - }) -}) diff --git a/packages/viewer/src/xr/human-mode/lib/capsule-collision.ts b/packages/viewer/src/xr/human-mode/lib/capsule-collision.ts deleted file mode 100644 index 0268e8b750..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/capsule-collision.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Box3, Line3, Matrix4, type Mesh, Quaternion, Vector3 } from 'three' - -const CAPSULE_RADIUS = 0.25 -const CAPSULE_SEGMENT_LENGTH = 0.8 -const CAPSULE_CENTER_FROM_EYE = 0.85 -const MAX_MOVEMENT_STEP = 0.1 -const COLLISION_ITERATIONS = 3 -const EPSILON = 1e-10 - -const inverseMatrix = new Matrix4() -const colliderScale = new Vector3() -const colliderPosition = new Vector3() -const colliderQuaternion = new Quaternion() -const worldSegment = new Line3(new Vector3(), new Vector3()) -const localSegment = new Line3(new Vector3(), new Vector3()) -const localBounds = new Box3() -const trianglePoint = new Vector3() -const capsulePoint = new Vector3() -const pushDirection = new Vector3() -const desiredWorldStart = new Vector3() -const resolvedWorldStart = new Vector3() -const correction = new Vector3() -const currentPosition = new Vector3() -const desiredPosition = new Vector3() -const stepMovement = new Vector3() -const roomMovement = new Vector3() -const resolvedRoomMovement = new Vector3() - -type BvhGeometry = Mesh['geometry'] & { - boundsTree?: { - shapecast(callbacks: { - intersectsBounds(bounds: Box3): boolean - intersectsTriangle(triangle: { - closestPointToSegment(segment: Line3, trianglePoint: Vector3, capsulePoint: Vector3): number - getNormal(target: Vector3): Vector3 - }): boolean - }): void - } -} - -function resolveColliderPenetration(collider: Mesh, eyePosition: Vector3) { - const geometry = collider.geometry as BvhGeometry - if (!geometry.boundsTree) return correction.set(0, 0, 0) - - collider.updateWorldMatrix(true, false) - inverseMatrix.copy(collider.matrixWorld).invert() - collider.matrixWorld.decompose(colliderPosition, colliderQuaternion, colliderScale) - const minimumScale = Math.max( - EPSILON, - Math.min(Math.abs(colliderScale.x), Math.abs(colliderScale.y), Math.abs(colliderScale.z)), - ) - const localRadius = CAPSULE_RADIUS / minimumScale - const halfSegment = CAPSULE_SEGMENT_LENGTH / 2 - const centerY = eyePosition.y - CAPSULE_CENTER_FROM_EYE - worldSegment.start.set(eyePosition.x, centerY + halfSegment, eyePosition.z) - worldSegment.end.set(eyePosition.x, centerY - halfSegment, eyePosition.z) - desiredWorldStart.copy(worldSegment.start) - localSegment.copy(worldSegment).applyMatrix4(inverseMatrix) - - for (let iteration = 0; iteration < COLLISION_ITERATIONS; iteration += 1) { - localBounds - .makeEmpty() - .expandByPoint(localSegment.start) - .expandByPoint(localSegment.end) - .expandByScalar(localRadius) - let collided = false - geometry.boundsTree.shapecast({ - intersectsBounds: (bounds) => bounds.intersectsBox(localBounds), - intersectsTriangle: (triangle) => { - const distance = triangle.closestPointToSegment(localSegment, trianglePoint, capsulePoint) - if (distance >= localRadius) return false - pushDirection.copy(capsulePoint).sub(trianglePoint) - if (pushDirection.lengthSq() <= EPSILON) triangle.getNormal(pushDirection) - else pushDirection.normalize() - localSegment.start.addScaledVector(pushDirection, localRadius - distance) - localSegment.end.addScaledVector(pushDirection, localRadius - distance) - collided = true - return false - }, - }) - if (!collided) break - } - - resolvedWorldStart.copy(localSegment.start).applyMatrix4(collider.matrixWorld) - return correction.copy(resolvedWorldStart).sub(desiredWorldStart) -} - -export function resolveCapsuleTranslation( - colliders: readonly Mesh[], - playerPosition: Vector3, - movement: Vector3, - target: Vector3, -) { - const distance = movement.length() - if (!Number.isFinite(distance)) return target.set(0, 0, 0) - const steps = Math.max(1, Math.ceil(distance / MAX_MOVEMENT_STEP)) - stepMovement.copy(movement).divideScalar(steps) - currentPosition.copy(playerPosition) - - for (let step = 0; step < steps; step += 1) { - desiredPosition.copy(currentPosition).add(stepMovement) - for (const collider of colliders) { - desiredPosition.add(resolveColliderPenetration(collider, desiredPosition)) - } - currentPosition.copy(desiredPosition) - } - return target.copy(currentPosition).sub(playerPosition) -} - -export function resolveRoomScaleOriginCorrection( - colliders: readonly Mesh[], - previousPlayerPosition: Vector3, - currentPlayerPosition: Vector3, - target: Vector3, -) { - roomMovement.copy(currentPlayerPosition).sub(previousPlayerPosition) - resolveCapsuleTranslation(colliders, previousPlayerPosition, roomMovement, resolvedRoomMovement) - return target.copy(resolvedRoomMovement).sub(roomMovement) -} diff --git a/packages/viewer/src/xr/human-mode/lib/comfort.ts b/packages/viewer/src/xr/human-mode/lib/comfort.ts deleted file mode 100644 index b1e0911b34..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/comfort.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const COMFORT_REFERENCE_SPEED = 1.5 -export const MAX_COMFORT_OPACITY = 0.22 - -export function resolveComfortOpacity(speed: number, referenceSpeed = COMFORT_REFERENCE_SPEED) { - if (!Number.isFinite(speed) || !Number.isFinite(referenceSpeed) || referenceSpeed <= 0) return 0 - return MAX_COMFORT_OPACITY * Math.min(1, Math.abs(speed) / referenceSpeed) -} diff --git a/packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts b/packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts deleted file mode 100644 index 1fab72ad35..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/hand-locomotion.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Vector3 } from 'three' -import { - HAND_DEAD_ZONE, - HAND_PINCH_RELEASE_DISTANCE, - HAND_PINCH_TOUCH_DISTANCE, - HAND_SPEED, - HAND_TURN_SPEED, - HAND_ZONE_RADIUS, -} from '../constants/human-mode-constants' - -export function resolveHandJoystickArrowRotations(handedness: XRHandedness) { - return handedness === 'right' - ? [Math.PI / 2, -Math.PI / 2] - : [0, Math.PI / 2, Math.PI, -Math.PI / 2] -} - -export function resolveHandControlLabel(handedness: XRHandedness) { - return handedness === 'left' ? 'MOVE' : 'TURN' -} - -export function resolveHandPinching(previousPinching: boolean, distance: number) { - if (!Number.isFinite(distance)) return false - return previousPinching - ? distance < HAND_PINCH_RELEASE_DISTANCE - : distance <= HAND_PINCH_TOUCH_DISTANCE -} - -const HAND_ZONE_HORIZONTAL_OFFSET = 0.2 -const HAND_ZONE_HEIGHT = 0.93 -const HAND_ZONE_DEPTH = -0.35 -const HAND_ZONE_HEAD_VERTICAL_OFFSET = -0.25 - -export function getHandLocomotionZoneCenter( - handedness: XRHandedness, - target = new Vector3(), - anchor?: Vector3, -) { - if (!anchor) - return target.set( - handedness === 'right' ? HAND_ZONE_HORIZONTAL_OFFSET : -HAND_ZONE_HORIZONTAL_OFFSET, - HAND_ZONE_HEIGHT, - HAND_ZONE_DEPTH, - ) - return target.set( - anchor.x + - (handedness === 'right' ? HAND_ZONE_HORIZONTAL_OFFSET : -HAND_ZONE_HORIZONTAL_OFFSET), - anchor.y + HAND_ZONE_HEAD_VERTICAL_OFFSET, - anchor.z + HAND_ZONE_DEPTH, - ) -} - -export function isInsideHandLocomotionZone( - position: Vector3, - handedness: XRHandedness, - anchor?: Vector3, -) { - if (handedness !== 'left' && handedness !== 'right') return false - const center = getHandLocomotionZoneCenter(handedness, new Vector3(), anchor) - return ( - Math.hypot(position.x - center.x, position.z - center.z) <= HAND_ZONE_RADIUS && - Math.abs(position.y - center.y) <= HAND_ZONE_RADIUS - ) -} - -export function normalizeHandLocomotionOffset(offset: number) { - const distance = Math.abs(offset) - if (!Number.isFinite(distance) || distance <= HAND_DEAD_ZONE) return 0 - const normalized = Math.min(1, (distance - HAND_DEAD_ZONE) / (HAND_ZONE_RADIUS - HAND_DEAD_ZONE)) - return Math.sign(offset) * normalized -} - -export function resolveHandLocomotionVelocity(offset: number, delta: number, speed = HAND_SPEED) { - if (!Number.isFinite(delta) || delta <= 0) return 0 - return normalizeHandLocomotionOffset(offset) * speed * delta -} - -export function resolveHandTurnDelta(offset: number, delta: number) { - return -normalizeHandLocomotionOffset(offset) * HAND_TURN_SPEED * Math.max(0, delta) -} diff --git a/packages/viewer/src/xr/human-mode/lib/hand-pose.ts b/packages/viewer/src/xr/human-mode/lib/hand-pose.ts deleted file mode 100644 index 1869db48d5..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/hand-pose.ts +++ /dev/null @@ -1,38 +0,0 @@ -export const PALM_UP_DOT_THRESHOLD = 0.5 - -type Point = { x: number; y: number; z: number } - -function isFinitePoint(point: Point) { - return Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z) -} - -export function isPalmFacingUp( - wrist: Point, - indexMetacarpal: Point, - pinkyMetacarpal: Point, - handedness: XRHandedness, - threshold = PALM_UP_DOT_THRESHOLD, -) { - if ( - !isFinitePoint(wrist) || - !isFinitePoint(indexMetacarpal) || - !isFinitePoint(pinkyMetacarpal) || - (handedness !== 'left' && handedness !== 'right') - ) - return false - - const indexX = indexMetacarpal.x - wrist.x - const indexY = indexMetacarpal.y - wrist.y - const indexZ = indexMetacarpal.z - wrist.z - const pinkyX = pinkyMetacarpal.x - wrist.x - const pinkyY = pinkyMetacarpal.y - wrist.y - const pinkyZ = pinkyMetacarpal.z - wrist.z - const normalY = indexZ * pinkyX - indexX * pinkyZ - const normalLength = Math.hypot( - indexY * pinkyZ - indexZ * pinkyY, - normalY, - indexX * pinkyY - indexY * pinkyX, - ) - if (normalLength === 0) return false - return (normalY * (handedness === 'right' ? 1 : -1)) / normalLength >= threshold -} diff --git a/packages/viewer/src/xr/human-mode/lib/haptics.ts b/packages/viewer/src/xr/human-mode/lib/haptics.ts deleted file mode 100644 index 11f8574a26..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/haptics.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function pulseInputSource( - inputSource: XRInputSource | undefined, - intensity = 0.2, - duration = 35, -) { - const actuator = inputSource?.gamepad?.hapticActuators?.[0] - if (!actuator || typeof actuator.pulse !== 'function') return false - void actuator.pulse(intensity, duration) - return true -} diff --git a/packages/viewer/src/xr/human-mode/lib/human-input.test.ts b/packages/viewer/src/xr/human-mode/lib/human-input.test.ts deleted file mode 100644 index d030573cab..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/human-input.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { Vector3 } from 'three' -import { HAND_TURN_SPEED, HAND_ZONE_RADIUS } from '../constants/human-mode-constants' -import { resolveComfortOpacity } from './comfort' -import { - getHandLocomotionZoneCenter, - isInsideHandLocomotionZone, - normalizeHandLocomotionOffset, - resolveHandControlLabel, - resolveHandLocomotionVelocity, - resolveHandPinching, - resolveHandTurnDelta, -} from './hand-locomotion' -import { resolveSnapTurnDirection, SNAP_TURN_ANGLE, shouldSnapTurn } from './snap-turn' - -describe('Human hand locomotion', () => { - test('mirrors body-relative activation zones', () => { - const anchor = new Vector3(3, 1.6, -4) - const left = getHandLocomotionZoneCenter('left', new Vector3(), anchor) - const right = getHandLocomotionZoneCenter('right', new Vector3(), anchor) - expect(left.toArray()).toEqual([2.8, 1.35, -4.35]) - expect(right.x).toBeCloseTo(3.2) - expect(isInsideHandLocomotionZone(left, 'left', anchor)).toBe(true) - expect(isInsideHandLocomotionZone(left, 'right', anchor)).toBe(false) - }) - - test('applies dead zones, pinch hysteresis, and hand roles', () => { - expect(normalizeHandLocomotionOffset(0.014)).toBe(0) - expect(normalizeHandLocomotionOffset(HAND_ZONE_RADIUS)).toBe(1) - expect(resolveHandPinching(false, 0.03)).toBe(true) - expect(resolveHandPinching(false, 0.035)).toBe(false) - expect(resolveHandPinching(true, 0.04)).toBe(true) - expect(resolveHandPinching(true, 0.045)).toBe(false) - expect(resolveHandControlLabel('left')).toBe('MOVE') - expect(resolveHandControlLabel('right')).toBe('TURN') - }) - - test('scales movement and turning from hand displacement', () => { - expect(resolveHandLocomotionVelocity(HAND_ZONE_RADIUS, 0.1)).toBeCloseTo(0.15) - expect(HAND_TURN_SPEED).toBe(Math.PI / 2) - expect(resolveHandTurnDelta(HAND_ZONE_RADIUS, 1)).toBe(-Math.PI / 2) - }) -}) - -describe('Human comfort and snap turn', () => { - test('scales the vignette with artificial movement speed', () => { - expect(resolveComfortOpacity(0)).toBe(0) - expect(resolveComfortOpacity(0.75)).toBeCloseTo(0.11) - expect(resolveComfortOpacity(3)).toBeCloseTo(0.22) - }) - - test('turns once per stick threshold crossing', () => { - expect(resolveSnapTurnDirection(0.8)).toBe(1) - expect(shouldSnapTurn(0, 1)).toBe(true) - expect(shouldSnapTurn(1, 1)).toBe(false) - expect(resolveSnapTurnDirection(0.1)).toBe(0) - expect(SNAP_TURN_ANGLE).toBe(Math.PI / 6) - }) -}) diff --git a/packages/viewer/src/xr/human-mode/lib/locomotion.test.ts b/packages/viewer/src/xr/human-mode/lib/locomotion.test.ts deleted file mode 100644 index 4b222906fc..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/locomotion.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import type { XRControllerState } from '@react-three/xr' -import { Vector3 } from 'three' -import { - getCameraRelativeRight, - getControllerThumbstickAxis, - normalizeMovementVector, - resolveLocomotionDelta, -} from './locomotion' - -function controllerWithAxes(axes: number[]) { - return { inputSource: { gamepad: { axes } } } as unknown as XRControllerState -} - -describe('Human controller locomotion', () => { - test('reads both XR thumbstick axis layouts', () => { - expect(getControllerThumbstickAxis(controllerWithAxes([0.25, -0.5]), 0)).toBe(0.25) - expect(getControllerThumbstickAxis(controllerWithAxes([0, 0, 0.4, -0.6]), 1)).toBe(-0.6) - }) - - test('keeps movement relative to the viewer heading', () => { - const right = new Vector3() - expect(getCameraRelativeRight(new Vector3(0, 0, -1), right).toArray()).toEqual([1, 0, 0]) - expect(getCameraRelativeRight(new Vector3(0, 0, 1), right).toArray()).toEqual([-1, 0, 0]) - }) - - test('caps stalled frames and normalizes diagonal movement', () => { - expect(resolveLocomotionDelta(1)).toBeCloseTo(1 / 30) - expect(resolveLocomotionDelta(1 / 60)).toBeCloseTo(1 / 60) - expect(resolveLocomotionDelta(0)).toBe(0) - expect(normalizeMovementVector(1, 1).x).toBeCloseTo(1 / Math.sqrt(2)) - expect(normalizeMovementVector(1, 1).z).toBeCloseTo(1 / Math.sqrt(2)) - }) -}) diff --git a/packages/viewer/src/xr/human-mode/lib/locomotion.ts b/packages/viewer/src/xr/human-mode/lib/locomotion.ts deleted file mode 100644 index ce9596184e..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/locomotion.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { XRControllerState } from '@react-three/xr' -import { Vector3 as ThreeVector3, type Vector3 } from 'three' - -export const MAX_LOCOMOTION_DELTA = 1 / 30 - -let artificialMovementSpeed = 0 - -export function resolveLocomotionDelta(deltaSeconds: number, maximum = MAX_LOCOMOTION_DELTA) { - if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0 - return Math.min(deltaSeconds, maximum) -} - -export function getControllerThumbstickAxis( - controllerState: XRControllerState | undefined, - axis: 0 | 1, -) { - const axes = controllerState?.inputSource.gamepad?.axes - const axisOffset = axes && axes.length >= 4 ? 2 : 0 - const axisValue = axes?.[axisOffset + axis] - if (Number.isFinite(axisValue)) return axisValue! - - const thumbstick = controllerState?.gamepad?.['xr-standard-thumbstick'] - return axis === 0 ? (thumbstick?.xAxis ?? 0) : (thumbstick?.yAxis ?? 0) -} - -export function normalizeMovementVector(x: number, z: number) { - const length = Math.hypot(x, z) - if (!Number.isFinite(length) || length === 0) return { x: 0, z: 0 } - const scale = Math.min(1, 1 / length) - return { x: x * scale, z: z * scale } -} - -export function getCameraRelativeRight(forward: Vector3, target = new ThreeVector3()) { - return target.set(-forward.z, 0, forward.x).normalize() -} - -export function setArtificialMovementSpeed(speed: number) { - artificialMovementSpeed = Number.isFinite(speed) ? Math.abs(speed) : 0 -} - -export function getArtificialMovementSpeed() { - return artificialMovementSpeed -} diff --git a/packages/viewer/src/xr/human-mode/lib/origin-navigation.ts b/packages/viewer/src/xr/human-mode/lib/origin-navigation.ts deleted file mode 100644 index f821c6faeb..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/origin-navigation.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Camera, Object3D, Vector3 } from 'three' - -export function translateOrigin( - origin: Object3D, - movement: Vector3, - playerPosition?: Vector3, - resolvedPlayerPosition?: Vector3, -) { - if (playerPosition && resolvedPlayerPosition) { - resolvedPlayerPosition.copy(playerPosition).add(movement) - origin.position.add(resolvedPlayerPosition.sub(playerPosition)) - } else { - origin.position.add(movement) - } - origin.position.y = Math.max(0, origin.position.y) -} - -export function rotateOriginAroundCamera( - origin: Object3D, - camera: Camera, - angle: number, - before: Vector3, - after: Vector3, -) { - camera.getWorldPosition(before) - origin.rotation.y += angle - camera.getWorldPosition(after) - origin.position.x += before.x - after.x - origin.position.z += before.z - after.z -} diff --git a/packages/viewer/src/xr/human-mode/lib/snap-turn.ts b/packages/viewer/src/xr/human-mode/lib/snap-turn.ts deleted file mode 100644 index c4acc9c8ab..0000000000 --- a/packages/viewer/src/xr/human-mode/lib/snap-turn.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SNAP_TURN_ANGLE, SNAP_TURN_THRESHOLD } from '../constants/human-mode-constants' - -export { SNAP_TURN_ANGLE, SNAP_TURN_THRESHOLD } - -export function resolveSnapTurnDirection(axis: number, threshold = SNAP_TURN_THRESHOLD) { - if (!Number.isFinite(axis)) return 0 - if (axis >= threshold) return 1 - if (axis <= -threshold) return -1 - return 0 -} - -export function shouldSnapTurn(previousDirection: number, nextDirection: number) { - return previousDirection === 0 && nextDirection !== 0 -} diff --git a/packages/viewer/src/xr/human-mode/store/collision-store.ts b/packages/viewer/src/xr/human-mode/store/collision-store.ts deleted file mode 100644 index d332f68034..0000000000 --- a/packages/viewer/src/xr/human-mode/store/collision-store.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Mesh } from 'three' -import { resolveCapsuleTranslation } from '../lib/capsule-collision' - -let activeColliders: readonly Mesh[] = [] - -export function setActiveHumanColliders(colliders: readonly Mesh[]) { - activeColliders = colliders -} - -export function resolveHumanCollisionTranslation( - playerPosition: Parameters[1], - movement: Parameters[2], - target: Parameters[3], -) { - return resolveCapsuleTranslation(activeColliders, playerPosition, movement, target) -} diff --git a/packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts b/packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts deleted file mode 100644 index cf7829cb9d..0000000000 --- a/packages/viewer/src/xr/human-mode/store/hand-locomotion-joystick.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Vector3 } from 'three' -import { createStore } from 'zustand/vanilla' - -type HandStateName = 'idle' | 'ready' | 'active' -type Handedness = 'left' | 'right' -type HandJoystickState = { - active: boolean - state: HandStateName - position: [number, number, number] -} - -export const handLocomotionJoystickStore = createStore>( - () => ({ - left: { active: false, state: 'idle', position: [0, 0, 0] }, - right: { active: false, state: 'idle', position: [0, 0, 0] }, - }), -) - -export function showHandLocomotionJoystick(position: Vector3, handedness: Handedness) { - handLocomotionJoystickStore.setState((state) => ({ - ...state, - [handedness]: { - active: true, - state: 'active', - position: [position.x, position.y, position.z], - }, - })) -} - -export function hideHandLocomotionJoystick(handedness?: Handedness) { - if (!handedness) { - handLocomotionJoystickStore.setState({ - left: { active: false, state: 'idle', position: [0, 0, 0] }, - right: { active: false, state: 'idle', position: [0, 0, 0] }, - }) - return - } - handLocomotionJoystickStore.setState((state) => ({ - ...state, - [handedness]: { ...state[handedness], active: false, state: 'idle' }, - })) -} - -export function setHandLocomotionState( - handedness: Handedness, - stateName: HandStateName, - position?: Vector3, -) { - handLocomotionJoystickStore.setState((state) => ({ - ...state, - [handedness]: { - ...state[handedness], - active: stateName === 'active', - state: stateName, - ...(position - ? { position: [position.x, position.y, position.z] as [number, number, number] } - : {}), - }, - })) -} diff --git a/packages/viewer/src/xr/human-mode/store/locomotion-settings.ts b/packages/viewer/src/xr/human-mode/store/locomotion-settings.ts deleted file mode 100644 index c020611c6d..0000000000 --- a/packages/viewer/src/xr/human-mode/store/locomotion-settings.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand' - -export const DEFAULT_LOCOMOTION_SETTINGS = { moveSpeed: 1.5, turnSensitivity: 1 } - -export type LocomotionSettings = typeof DEFAULT_LOCOMOTION_SETTINGS & { - setMoveSpeed(value: number): void - setTurnSensitivity(value: number): void -} - -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) - -export const useLocomotionSettings = create((set) => ({ - ...DEFAULT_LOCOMOTION_SETTINGS, - setMoveSpeed: (moveSpeed) => set({ moveSpeed: clamp(moveSpeed, 0.25, 4) }), - setTurnSensitivity: (turnSensitivity) => set({ turnSensitivity: clamp(turnSensitivity, 0.5, 2) }), -})) diff --git a/packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx b/packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx deleted file mode 100644 index b192299b00..0000000000 --- a/packages/viewer/src/xr/human-mode/ui/comfort-vignette.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { useFrame, useThree } from '@react-three/fiber' -import { useEffect, useRef } from 'react' -import { MathUtils, type Mesh, type MeshBasicMaterial } from 'three' -import { OVERLAY_LAYER } from '../../../lib/layers' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' -import { resolveComfortOpacity } from '../lib/comfort' -import { getArtificialMovementSpeed } from '../lib/locomotion' - -export function ComfortVignette() { - const camera = useThree((state) => state.camera) - const mode = useXRPlayerMode((state) => state.mode) - const mesh = useRef(null) - const material = useRef(null) - - useEffect(() => { - if (!mesh.current) return - const currentMesh = mesh.current - camera.add(currentMesh) - return () => { - camera.remove(currentMesh) - } - }, [camera]) - - useFrame((_, delta) => { - if (!material.current) return - const target = - mode === XR_PLAYER_MODES.HUMAN ? resolveComfortOpacity(getArtificialMovementSpeed()) : 0 - material.current.opacity = MathUtils.damp(material.current.opacity, target, 18, delta) - }) - - return ( - - - - - ) -} diff --git a/packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx b/packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx deleted file mode 100644 index ed42e04a89..0000000000 --- a/packages/viewer/src/xr/human-mode/ui/hand-locomotion-zone.tsx +++ /dev/null @@ -1,178 +0,0 @@ -'use client' - -import { Text } from '@react-three/drei' -import { useFrame, useThree } from '@react-three/fiber' -import { useXR } from '@react-three/xr' -import { useMemo, useRef } from 'react' -import { DoubleSide, Euler, type Group, Quaternion, Vector3 } from 'three' -import { useStore } from 'zustand' -import { OVERLAY_LAYER } from '../../../lib/layers' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../../mode-switching/store/player-mode' -import { HAND_ZONE_RADIUS } from '../constants/human-mode-constants' -import { - getHandLocomotionZoneCenter, - resolveHandControlLabel, - resolveHandJoystickArrowRotations, -} from '../lib/hand-locomotion' -import { handLocomotionJoystickStore } from '../store/hand-locomotion-joystick' - -const ignoreRaycast = () => null -const JOYSTICK_RADIUS = 0.05 -const JOYSTICK_ARROW_DISTANCE = 0.033 -const JOYSTICK_COLOR = '#03070c' -const ACTIVATION_RING_WIDTH = 0.002 - -function HandLocomotionJoystick({ handedness }: { handedness: 'left' | 'right' }) { - const group = useRef(null) - const origin = useXR((state) => state.origin) - const parentInverse = useMemo(() => new Quaternion(), []) - const groundRotation = useMemo( - () => new Quaternion().setFromEuler(new Euler(-Math.PI / 2, 0, 0)), - [], - ) - const control = useStore(handLocomotionJoystickStore, (state) => state[handedness]) - const arrows = resolveHandJoystickArrowRotations(handedness) - - useFrame(() => { - if (!group.current || !origin) return - parentInverse.copy(origin.quaternion).invert() - group.current.quaternion.copy(parentInverse).multiply(groundRotation) - }) - - return ( - - - - - - {arrows.map((rotation) => ( - - - - - - - ))} - - ) -} - -function HandActivationZone({ handedness }: { handedness: 'left' | 'right' }) { - const group = useRef(null) - const camera = useThree((state) => state.camera) - const origin = useXR((state) => state.origin) - const cameraWorld = useMemo(() => new Vector3(), []) - const anchor = useMemo(() => new Vector3(), []) - const control = useStore(handLocomotionJoystickStore, (state) => state[handedness]) - - useFrame(() => { - if (!group.current || !origin) return - camera.getWorldPosition(cameraWorld) - anchor.copy(cameraWorld) - origin.worldToLocal(anchor) - getHandLocomotionZoneCenter(handedness, group.current.position, anchor) - group.current.quaternion.copy(origin.quaternion).invert() - }) - - const color = - control.state === 'active' ? '#38bdf8' : control.state === 'ready' ? '#fbbf24' : '#64748b' - return ( - - - - - - - - - - - {resolveHandControlLabel(handedness)} - - - ) -} - -export function HandLocomotionZone() { - const mode = useXRPlayerMode((state) => state.mode) - const hands = useXR((state) => - state.inputSourceStates - .filter(({ type }) => type === 'hand') - .map(({ inputSource }) => inputSource.handedness), - ) - if (mode !== XR_PLAYER_MODES.HUMAN) return null - return ( - <> - {hands.includes('left') && ( - <> - - - - )} - {hands.includes('right') && ( - <> - - - - )} - - ) -} diff --git a/packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx b/packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx deleted file mode 100644 index 995b803ee7..0000000000 --- a/packages/viewer/src/xr/human-mode/ui/human-mode-controls.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client' - -import type { RefObject } from 'react' -import type { Object3D } from 'three' -import { ControllerLocomotion } from '../input/controller-locomotion' -import { HumanCollisionRig } from '../input/human-collision-rig' -import { ComfortVignette } from './comfort-vignette' -import { HandLocomotionZone } from './hand-locomotion-zone' - -export function HumanModeControls({ sceneRootRef }: { sceneRootRef: RefObject }) { - return ( - - - - - - - ) -} diff --git a/packages/viewer/src/xr/input-visuals.tsx b/packages/viewer/src/xr/input-visuals.tsx deleted file mode 100644 index 980b1356d4..0000000000 --- a/packages/viewer/src/xr/input-visuals.tsx +++ /dev/null @@ -1,174 +0,0 @@ -'use client' - -import { - DefaultXRController, - DefaultXRHand, - useXRInputSourceStateContext, - XRSpace, -} from '@react-three/xr' -import { OVERLAY_LAYER } from '../lib/layers' - -const HAND_JOINTS: readonly XRHandJoint[] = [ - 'wrist', - 'thumb-metacarpal', - 'thumb-phalanx-proximal', - 'thumb-phalanx-distal', - 'thumb-tip', - 'index-finger-metacarpal', - 'index-finger-phalanx-proximal', - 'index-finger-phalanx-intermediate', - 'index-finger-phalanx-distal', - 'index-finger-tip', - 'middle-finger-metacarpal', - 'middle-finger-phalanx-proximal', - 'middle-finger-phalanx-intermediate', - 'middle-finger-phalanx-distal', - 'middle-finger-tip', - 'ring-finger-metacarpal', - 'ring-finger-phalanx-proximal', - 'ring-finger-phalanx-intermediate', - 'ring-finger-phalanx-distal', - 'ring-finger-tip', - 'pinky-finger-metacarpal', - 'pinky-finger-phalanx-proximal', - 'pinky-finger-phalanx-intermediate', - 'pinky-finger-phalanx-distal', - 'pinky-finger-tip', -] - -export function XRControllerVisual() { - const state = useXRInputSourceStateContext('controller') - const accent = state.inputSource.handedness === 'left' ? '#38bdf8' : '#fb923c' - - return ( - - - - - - - - - - - - - - - - - - - - - - - ) -} - -export function XRHandVisual() { - const state = useXRInputSourceStateContext('hand') - const color = state.inputSource.handedness === 'left' ? '#bae6fd' : '#fed7aa' - const side = state.inputSource.handedness === 'left' ? -1 : 1 - - return ( - <> - - - - - - {[-0.045, -0.015, 0.015, 0.045].map((x, index) => ( - - - - - ))} - - - - - - {HAND_JOINTS.map((joint) => ( - - - - - - - ))} - - ) -} - -export function VisibleXRController() { - return ( - <> - - - - ) -} - -export function VisibleXRHand() { - return ( - <> - - - - ) -} - -export { HAND_JOINTS } diff --git a/packages/viewer/src/xr/mode-switching/index.ts b/packages/viewer/src/xr/mode-switching/index.ts deleted file mode 100644 index 9d8e8463d3..0000000000 --- a/packages/viewer/src/xr/mode-switching/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - toggleXRPlayerMode, - useXRPlayerMode, - XR_PLAYER_MODES, - type XRPlayerMode, -} from './store/player-mode' -export { PlayerModeScene } from './ui/player-mode-scene' diff --git a/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts b/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts deleted file mode 100644 index 6311e4a9da..0000000000 --- a/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { Object3D, Vector3 } from 'three' -import { - captureGodSceneTransform, - resetSceneForHumanScale, - resolveXRHumanOriginTarget, - restoreGodSceneTransform, -} from './scene-scale-transition' - -describe('God and Human scene transition', () => { - test('restores the God transform after world-scale Human mode', () => { - const root = new Object3D() - root.position.set(2, 3, 4) - root.rotation.set(0, 0.5, 0) - root.scale.setScalar(2) - const transform = captureGodSceneTransform(root) - - resetSceneForHumanScale(root) - expect(root.position.toArray()).toEqual([0, 0, 0]) - expect(root.scale.toArray()).toEqual([1, 1, 1]) - - expect(restoreGodSceneTransform(root, transform)).toBe(true) - expect(root.position.toArray()).toEqual([2, 3, 4]) - expect(root.rotation.y).toBeCloseTo(0.5) - expect(root.scale.toArray()).toEqual([2, 2, 2]) - }) - - test('places the tracked viewer over the selected Human point', () => { - const target = resolveXRHumanOriginTarget(new Vector3(4, 0, -2), new Vector3(0.25, 1.65, -0.5)) - expect(target.toArray()).toEqual([3.75, 0, -1.5]) - }) -}) diff --git a/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts b/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts deleted file mode 100644 index 2993ebaaae..0000000000 --- a/packages/viewer/src/xr/mode-switching/lib/scene-scale-transition.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type Euler, type Object3D, Vector3 } from 'three' - -export type SceneTransform = { - position: Vector3 - rotation: Euler - scale: Vector3 -} - -export function captureGodSceneTransform(sceneRoot: Object3D | null): SceneTransform | null { - if (!sceneRoot) return null - return { - position: sceneRoot.position.clone(), - rotation: sceneRoot.rotation.clone(), - scale: sceneRoot.scale.clone(), - } -} - -export function resetSceneForHumanScale(sceneRoot: Object3D | null) { - if (!sceneRoot) return - sceneRoot.position.set(0, 0, 0) - sceneRoot.rotation.set(0, 0, 0) - sceneRoot.scale.setScalar(1) - sceneRoot.updateWorldMatrix(true, false) -} - -export function restoreGodSceneTransform( - sceneRoot: Object3D | null, - transform: SceneTransform | null, -) { - if (!sceneRoot || !transform) return false - sceneRoot.position.copy(transform.position) - sceneRoot.rotation.copy(transform.rotation) - sceneRoot.scale.copy(transform.scale) - sceneRoot.updateWorldMatrix(true, false) - return true -} - -export function resolveHumanPointInScene( - sceneRoot: Object3D | null, - worldPosition: Vector3, - worldDirection: Vector3, - target = new Vector3(), - maximumDistance = 5, -) { - if (!sceneRoot) return target.set(worldPosition.x, 0, worldPosition.z) - - sceneRoot.updateWorldMatrix(true, false) - const inverseSceneMatrix = sceneRoot.matrixWorld.clone().invert() - const localPosition = worldPosition.clone().applyMatrix4(inverseSceneMatrix) - const localDirection = worldDirection.clone().transformDirection(inverseSceneMatrix) - const worldScale = sceneRoot.getWorldScale(new Vector3()) - const minimumScale = Math.max( - 1e-6, - Math.min(Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z)), - ) - const distance = - Math.abs(localDirection.y) > 0.05 ? -localPosition.y / localDirection.y : maximumDistance - const clampedDistance = Math.min(maximumDistance / minimumScale, Math.max(1, distance)) - return target.set( - localPosition.x + localDirection.x * clampedDistance, - 0, - localPosition.z + localDirection.z * clampedDistance, - ) -} - -export function resolveXRHumanOriginTarget( - humanPoint: Vector3, - viewerLocalPosition: Vector3, - target = new Vector3(), -) { - return target.set(humanPoint.x - viewerLocalPosition.x, 0, humanPoint.z - viewerLocalPosition.z) -} diff --git a/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts b/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts deleted file mode 100644 index 57a60af0c8..0000000000 --- a/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { Vector3 } from 'three' -import { - advanceThumbModeGesture, - areThumbTipsTouching, - THUMB_TOUCH_TRIGGER_SECONDS, -} from './thumb-mode-gesture' - -describe('hand-tracked player mode switching', () => { - test('recognizes two visible touching thumb tips', () => { - expect( - areThumbTipsTouching( - { visible: true, position: new Vector3(0, 0, 0) }, - { visible: true, position: new Vector3(0.02, 0, 0) }, - ), - ).toBe(true) - }) - - test('toggles once after a held touch and rearms after release', () => { - const state = { elapsed: 0, triggered: false } - expect(advanceThumbModeGesture(state, true, THUMB_TOUCH_TRIGGER_SECONDS - 0.01)).toBe(false) - expect(advanceThumbModeGesture(state, true, 0.01)).toBe(true) - expect(advanceThumbModeGesture(state, true, 1)).toBe(false) - advanceThumbModeGesture(state, false, 0.016) - expect(advanceThumbModeGesture(state, true, THUMB_TOUCH_TRIGGER_SECONDS)).toBe(true) - }) -}) diff --git a/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts b/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts deleted file mode 100644 index dc3a5a39dc..0000000000 --- a/packages/viewer/src/xr/mode-switching/lib/thumb-mode-gesture.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Vector3 } from 'three' - -export const THUMB_TOUCH_TRIGGER_SECONDS = 0.8 -export const THUMB_TOUCH_DISTANCE = 0.045 - -type TrackedThumb = { - position: Vector3 - visible: boolean -} - -export type ThumbModeGestureState = { - elapsed: number - triggered: boolean -} - -export function areThumbTipsTouching( - left: TrackedThumb | null, - right: TrackedThumb | null, - maximumDistance = THUMB_TOUCH_DISTANCE, -) { - return ( - Boolean(left?.visible && right?.visible) && - left!.position.distanceTo(right!.position) <= maximumDistance - ) -} - -export function advanceThumbModeGesture( - state: ThumbModeGestureState, - touching: boolean, - deltaSeconds: number, -) { - if (!touching) { - state.elapsed = 0 - state.triggered = false - return false - } - if (state.triggered) return false - state.elapsed += Math.max(0, deltaSeconds) - if (state.elapsed < THUMB_TOUCH_TRIGGER_SECONDS) return false - state.triggered = true - return true -} diff --git a/packages/viewer/src/xr/mode-switching/store/player-mode.test.ts b/packages/viewer/src/xr/mode-switching/store/player-mode.test.ts deleted file mode 100644 index 2ce7392861..0000000000 --- a/packages/viewer/src/xr/mode-switching/store/player-mode.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { beforeEach, describe, expect, test } from 'bun:test' -import { toggleXRPlayerMode, useXRPlayerMode, XR_PLAYER_MODES } from './player-mode' - -describe('XR player mode', () => { - beforeEach(() => useXRPlayerMode.getState().setMode(XR_PLAYER_MODES.GOD)) - - test('toggles between God and Human mode', () => { - toggleXRPlayerMode() - expect(useXRPlayerMode.getState().mode).toBe(XR_PLAYER_MODES.HUMAN) - toggleXRPlayerMode() - expect(useXRPlayerMode.getState().mode).toBe(XR_PLAYER_MODES.GOD) - }) -}) diff --git a/packages/viewer/src/xr/mode-switching/store/player-mode.ts b/packages/viewer/src/xr/mode-switching/store/player-mode.ts deleted file mode 100644 index fc7e0f3e84..0000000000 --- a/packages/viewer/src/xr/mode-switching/store/player-mode.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { create } from 'zustand' - -export const XR_PLAYER_MODES = { - GOD: 'god', - HUMAN: 'human', -} as const - -export type XRPlayerMode = (typeof XR_PLAYER_MODES)[keyof typeof XR_PLAYER_MODES] - -type XRPlayerModeState = { - mode: XRPlayerMode - setMode(mode: XRPlayerMode): void - toggle(): void -} - -export const useXRPlayerMode = create((set) => ({ - mode: XR_PLAYER_MODES.GOD, - setMode: (mode) => set({ mode }), - toggle: () => - set((state) => ({ - mode: state.mode === XR_PLAYER_MODES.GOD ? XR_PLAYER_MODES.HUMAN : XR_PLAYER_MODES.GOD, - })), -})) - -export function toggleXRPlayerMode() { - useXRPlayerMode.getState().toggle() -} diff --git a/packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx b/packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx deleted file mode 100644 index 1527e13967..0000000000 --- a/packages/viewer/src/xr/mode-switching/ui/player-mode-scene.tsx +++ /dev/null @@ -1,300 +0,0 @@ -'use client' - -import { useFrame, useThree } from '@react-three/fiber' -import { - CombinedPointer, - DefaultXRController, - DefaultXRHand, - useXR, - useXRInputSourceState, - useXRInputSourceStateContext, - type XRControllerState, - XRSpace, -} from '@react-three/xr' -import { type ComponentType, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react' -import { Euler, type Group, type Object3D, Vector3 } from 'three' -import { DistanceAwareRayPointer } from '../../distance-aware-ray-pointer' -import { GOD_ORIGIN_POSITION, GOD_ORIGIN_ROTATION } from '../../god-mode' -import { GodModeHandControls } from '../../god-mode/input/god-mode-hand-controls' -import { GodModeControls } from '../../god-mode/ui/god-mode-controls' -import { HumanModeHandControls } from '../../human-mode/input/hand-locomotion' -import { pulseInputSource } from '../../human-mode/lib/haptics' -import { HumanModeControls } from '../../human-mode/ui/human-mode-controls' -import { - VisibleXRController, - VisibleXRHand, - XRControllerVisual, - XRHandVisual, -} from '../../input-visuals' -import { DISTANCE_AWARE_RAY_POINTER_OPTIONS } from '../../pointer-cursor' -import { isR3FPointerTarget } from '../../pointer-filter' -import type { ViewerXRStore } from '../../store' -import { - captureGodSceneTransform, - resetSceneForHumanScale, - resolveHumanPointInScene, - resolveXRHumanOriginTarget, - restoreGodSceneTransform, - type SceneTransform, -} from '../lib/scene-scale-transition' -import { - advanceThumbModeGesture, - areThumbTipsTouching, - type ThumbModeGestureState, -} from '../lib/thumb-mode-gesture' -import { useXRPlayerMode, XR_PLAYER_MODES } from '../store/player-mode' - -function PlayerModeDefaultHand() { - return ( - <> - - - - - - - ) -} - -function PlayerModeHandInput() { - return ( - <> - - - - - - ) -} - -type InputSourceOverlay = ComponentType<{ type: 'controller' | 'hand' }> - -function createPlayerModeHandInput(InputSourceOverlay: InputSourceOverlay) { - return function PlayerModeHandInputWithOverlay() { - return ( - <> - - - - - - - ) - } -} - -function PlayerModeDefaultController() { - return ( - <> - - - - - - - ) -} - -function createPlayerModeControllerInput(InputSourceOverlay?: InputSourceOverlay) { - return function PlayerModeControllerInputWithOverlay() { - return ( - <> - - {InputSourceOverlay && } - - ) - } -} - -type Handedness = 'left' | 'right' - -const thumbObjects: Record = { left: null, right: null } - -function PlayerModeHandThumbInput() { - const state = useXRInputSourceStateContext('hand') - const handedness = state.inputSource.handedness - const setThumbObject = useCallback( - (object: Object3D | null) => { - if (handedness === 'left' || handedness === 'right') thumbObjects[handedness] = object - }, - [handedness], - ) - - return -} - -function PlayerModeHandToggle({ disabled = false }: { disabled?: boolean }) { - const leftHand = useXRInputSourceState('hand', 'left') - const rightHand = useXRInputSourceState('hand', 'right') - const leftThumb = useRef({ position: new Vector3(), visible: false }) - const rightThumb = useRef({ position: new Vector3(), visible: false }) - const gesture = useRef({ elapsed: 0, triggered: false }) - const selectionBlockedGesture = useRef(false) - - useFrame((_, delta) => { - if (disabled) return - const leftObject = thumbObjects.left - const rightObject = thumbObjects.right - leftThumb.current.visible = leftObject?.visible === true - rightThumb.current.visible = rightObject?.visible === true - if (leftThumb.current.visible) leftObject!.getWorldPosition(leftThumb.current.position) - if (rightThumb.current.visible) rightObject!.getWorldPosition(rightThumb.current.position) - - const selecting = - leftHand?.inputSource.gamepad?.buttons[0]?.pressed === true || - rightHand?.inputSource.gamepad?.buttons[0]?.pressed === true - const touching = areThumbTipsTouching(leftThumb.current, rightThumb.current) - if (selecting) selectionBlockedGesture.current = true - else if (!touching) selectionBlockedGesture.current = false - if ( - advanceThumbModeGesture(gesture.current, touching && !selectionBlockedGesture.current, delta) - ) { - useXRPlayerMode.getState().toggle() - } - }) - - return null -} - -function isModeButtonPressed(controller: XRControllerState | undefined) { - if (controller?.gamepad?.['y-button']?.state === 'pressed') return true - return controller?.inputSource.gamepad?.buttons[5]?.pressed === true -} - -function PlayerModeControllerToggle() { - const leftController = useXRInputSourceState('controller', 'left') - const pressed = useRef(false) - - useFrame(() => { - const nextPressed = isModeButtonPressed(leftController) - if (!pressed.current && nextPressed) { - useXRPlayerMode.getState().toggle() - pulseInputSource(leftController?.inputSource, 0.25, 35) - } - pressed.current = nextPressed - }) - return null -} - -function PlayerModeRig({ sceneRootRef }: { sceneRootRef: React.RefObject }) { - const camera = useThree((state) => state.camera) - const origin = useXR((state) => state.origin) - const mode = useXRPlayerMode((state) => state.mode) - const previousMode = useRef(mode) - const transitionActive = useRef(false) - const godTransform = useRef(null) - const cameraWorldPosition = useRef(new Vector3()) - const cameraDirection = useRef(new Vector3()) - const cameraLocalPosition = useRef(new Vector3()) - const humanPoint = useRef(new Vector3()) - const targetPosition = useRef(new Vector3()) - const targetRotation = useRef(new Euler()) - - useFrame((_, delta) => { - const root = sceneRootRef.current - if (!root || !origin) return - - if (mode !== previousMode.current) { - if (mode === XR_PLAYER_MODES.HUMAN) { - godTransform.current = captureGodSceneTransform(root) - camera.getWorldPosition(cameraWorldPosition.current) - camera.getWorldDirection(cameraDirection.current) - resolveHumanPointInScene( - root, - cameraWorldPosition.current, - cameraDirection.current, - humanPoint.current, - ) - cameraLocalPosition.current.copy(cameraWorldPosition.current) - origin.worldToLocal(cameraLocalPosition.current) - resolveXRHumanOriginTarget( - humanPoint.current, - cameraLocalPosition.current, - targetPosition.current, - ) - resetSceneForHumanScale(root) - targetRotation.current.set(0, 0, 0) - } else { - restoreGodSceneTransform(root, godTransform.current) - targetPosition.current.copy(GOD_ORIGIN_POSITION) - targetRotation.current.copy(GOD_ORIGIN_ROTATION) - } - previousMode.current = mode - transitionActive.current = true - } - - if (!transitionActive.current) return - - const blend = 1 - Math.exp(-delta * 8) - origin.position.lerp(targetPosition.current, blend) - origin.rotation.x += (targetRotation.current.x - origin.rotation.x) * blend - origin.rotation.y += (targetRotation.current.y - origin.rotation.y) * blend - origin.rotation.z += (targetRotation.current.z - origin.rotation.z) * blend - if (origin.position.distanceTo(targetPosition.current) < 0.002) { - origin.position.copy(targetPosition.current) - origin.rotation.copy(targetRotation.current) - transitionActive.current = false - } - }) - - return null -} - -export function PlayerModeScene({ - children, - inputSourceOverlay, - store, -}: { - children: ReactNode - inputSourceOverlay?: InputSourceOverlay - store: ViewerXRStore -}) { - const sceneRootRef = useRef(null) - const HandInput = useMemo( - () => - inputSourceOverlay ? createPlayerModeHandInput(inputSourceOverlay) : PlayerModeHandInput, - [inputSourceOverlay], - ) - const ControllerInput = useMemo( - () => createPlayerModeControllerInput(inputSourceOverlay), - [inputSourceOverlay], - ) - - useEffect(() => { - useXRPlayerMode.getState().setMode(XR_PLAYER_MODES.GOD) - store.setHand(HandInput) - store.setController(ControllerInput) - return () => { - store.setHand(VisibleXRHand) - store.setController(VisibleXRController) - useXRPlayerMode.getState().setMode(XR_PLAYER_MODES.GOD) - } - }, [ControllerInput, HandInput, store]) - - return ( - <> - - - - - - - {children} - - - ) -} diff --git a/packages/viewer/src/xr/pointer-cursor.test.ts b/packages/viewer/src/xr/pointer-cursor.test.ts deleted file mode 100644 index 4b673d940f..0000000000 --- a/packages/viewer/src/xr/pointer-cursor.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { - DISTANCE_AWARE_RAY_POINTER_OPTIONS, - POINTER_CURSOR_INNER_RADIUS, - POINTER_CURSOR_MAX_SIZE, - POINTER_CURSOR_MIN_SIZE, - POINTER_CURSOR_OUTER_RADIUS, - resolvePointerCursorSize, -} from './pointer-cursor' -import { PointerRingMaterial } from './pointer-ring-material' - -describe('XR pointer cursor styling', () => { - test('uses a real ring geometry for the intersection cursor', () => { - expect(POINTER_CURSOR_INNER_RADIUS).toBe(0.32) - expect(POINTER_CURSOR_OUTER_RADIUS).toBe(0.5) - expect(DISTANCE_AWARE_RAY_POINTER_OPTIONS.cursorModel.cursorOffset).toBeGreaterThan(0) - expect(PointerRingMaterial).toBeDefined() - }) -}) - -describe('resolvePointerCursorSize', () => { - test('grows the cursor as the ray intersection gets farther away', () => { - const near = resolvePointerCursorSize(0.2) - const medium = resolvePointerCursorSize(1) - const far = resolvePointerCursorSize(4) - - expect(near).toBeLessThan(medium) - expect(medium).toBeLessThan(far) - }) - - test('clamps the cursor to visible minimum and maximum sizes', () => { - expect(resolvePointerCursorSize(0)).toBe(POINTER_CURSOR_MIN_SIZE) - expect(resolvePointerCursorSize(Number.NaN)).toBe(POINTER_CURSOR_MIN_SIZE) - expect(resolvePointerCursorSize(100)).toBe(POINTER_CURSOR_MAX_SIZE) - }) -}) diff --git a/packages/viewer/src/xr/pointer-cursor.ts b/packages/viewer/src/xr/pointer-cursor.ts deleted file mode 100644 index 4c435149d3..0000000000 --- a/packages/viewer/src/xr/pointer-cursor.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { DefaultXRInputSourceRayPointerOptions } from '@react-three/xr' - -export const POINTER_CURSOR_MIN_SIZE = 0.012 -export const POINTER_CURSOR_MAX_SIZE = 0.14 -export const POINTER_CURSOR_INNER_RADIUS = 0.32 -export const POINTER_CURSOR_OUTER_RADIUS = 0.5 - -export function resolvePointerCursorSize(distance: number): number { - if (!Number.isFinite(distance)) return POINTER_CURSOR_MIN_SIZE - return Math.max(POINTER_CURSOR_MIN_SIZE, Math.min(POINTER_CURSOR_MAX_SIZE, distance * 0.06)) -} - -export const DISTANCE_AWARE_RAY_POINTER_OPTIONS = { - clickThresholdMs: Number.POSITIVE_INFINITY, - minDistance: 0, - rayModel: { - color: '#7dd3fc', - }, - cursorModel: { - color: '#7dd3fc', - opacity: 0.9, - cursorOffset: 0.008, - }, -} satisfies DefaultXRInputSourceRayPointerOptions diff --git a/packages/viewer/src/xr/pointer-filter.test.ts b/packages/viewer/src/xr/pointer-filter.test.ts deleted file mode 100644 index dd341c5380..0000000000 --- a/packages/viewer/src/xr/pointer-filter.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { Group, Mesh } from 'three' -import { isDirectR3FPointerTarget, isR3FPointerTarget } from './pointer-filter' - -describe('isDirectR3FPointerTarget', () => { - test('keeps an explicit collision mesh ahead of its passive rendered sibling', () => { - const passiveWallBody = new Mesh() - const wallCollisionMesh = new Mesh() - const interactiveLevelWrapper = new Group() - - ;(interactiveLevelWrapper as Group & { __r3f: { eventCount: number } }).__r3f = { - eventCount: 6, - } - interactiveLevelWrapper.add(passiveWallBody, wallCollisionMesh) - ;(wallCollisionMesh as Mesh & { __r3f: { eventCount: number } }).__r3f = { eventCount: 6 } - - expect(isDirectR3FPointerTarget(passiveWallBody)).toBe(false) - expect(isDirectR3FPointerTarget(wallCollisionMesh)).toBe(true) - expect(isR3FPointerTarget(passiveWallBody)).toBe(false) - expect(isR3FPointerTarget(wallCollisionMesh)).toBe(true) - }) - - test('keeps an explicit collision child ahead of its passive rendered parent', () => { - const passiveWallBody = new Mesh() - const wallCollisionMesh = new Mesh() - const interactiveWrapper = new Group() - - ;(interactiveWrapper as Group & { __r3f: { eventCount: number } }).__r3f = { - eventCount: 6, - } - ;(wallCollisionMesh as Mesh & { __r3f: { eventCount: number } }).__r3f = { eventCount: 6 } - passiveWallBody.add(wallCollisionMesh) - interactiveWrapper.add(passiveWallBody) - - expect(isR3FPointerTarget(passiveWallBody)).toBe(false) - expect(isR3FPointerTarget(wallCollisionMesh)).toBe(true) - }) - - test('inherits pointer handlers for nested imported meshes', () => { - const interactiveItemWrapper = new Group() - const importedGroup = new Group() - const importedMesh = new Mesh() - ;(interactiveItemWrapper as Group & { __r3f: { eventCount: number } }).__r3f = { - eventCount: 6, - } - interactiveItemWrapper.add(importedGroup) - importedGroup.add(importedMesh) - - expect(isR3FPointerTarget(importedMesh)).toBe(true) - }) - - test('rejects geometry with no eventful ancestor', () => { - expect(isR3FPointerTarget(new Mesh())).toBe(false) - }) -}) diff --git a/packages/viewer/src/xr/pointer-filter.ts b/packages/viewer/src/xr/pointer-filter.ts deleted file mode 100644 index b9b9073b72..0000000000 --- a/packages/viewer/src/xr/pointer-filter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Object3D } from 'three' - -type R3FPointerObject = Object3D & { - __r3f?: { eventCount?: number } -} - -export function isDirectR3FPointerTarget(object: Object3D): boolean { - return ((object as R3FPointerObject).__r3f?.eventCount ?? 0) > 0 -} - -export function isR3FPointerTarget(object: Object3D): boolean { - let current: Object3D | null = object - while (current) { - if (current.children.some(isDirectR3FPointerTarget)) return false - if (isDirectR3FPointerTarget(current)) return true - current = current.parent - } - return false -} diff --git a/packages/viewer/src/xr/pointer-ring-material.ts b/packages/viewer/src/xr/pointer-ring-material.ts deleted file mode 100644 index 31a8d90b0d..0000000000 --- a/packages/viewer/src/xr/pointer-ring-material.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DoubleSide, MeshBasicMaterial } from 'three' - -export class PointerRingMaterial extends MeshBasicMaterial { - constructor() { - super({ - transparent: true, - toneMapped: false, - depthWrite: false, - side: DoubleSide, - }) - } -} diff --git a/packages/viewer/src/xr/presentation-background.test.ts b/packages/viewer/src/xr/presentation-background.test.ts deleted file mode 100644 index b7e33fab8f..0000000000 --- a/packages/viewer/src/xr/presentation-background.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { immersiveXRBackgroundColor } from './presentation-background' - -describe('immersive XR background', () => { - test('uses the scene background instead of flattening the blue zenith color', () => { - expect(immersiveXRBackgroundColor('studio')).toBe('#fbfbfa') - }) -}) diff --git a/packages/viewer/src/xr/presentation-background.ts b/packages/viewer/src/xr/presentation-background.ts deleted file mode 100644 index 79c4aa2e66..0000000000 --- a/packages/viewer/src/xr/presentation-background.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getSceneTheme } from '../lib/scene-themes' - -export function immersiveXRBackgroundColor(sceneTheme: string) { - return getSceneTheme(sceneTheme).background -} diff --git a/packages/viewer/src/xr/presentation-context.tsx b/packages/viewer/src/xr/presentation-context.tsx deleted file mode 100644 index 968aec6bcc..0000000000 --- a/packages/viewer/src/xr/presentation-context.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client' - -import { createContext, type ReactNode, useContext } from 'react' - -const ImmersiveXRPresentationContext = createContext(false) - -export function ImmersiveXRPresentationProvider({ - children, - enabled, -}: { - children: ReactNode - enabled: boolean -}) { - return ( - - {children} - - ) -} - -export function useImmersiveXRPresentation() { - return useContext(ImmersiveXRPresentationContext) -} diff --git a/packages/viewer/src/xr/session-root.tsx b/packages/viewer/src/xr/session-root.tsx deleted file mode 100644 index ee958a5459..0000000000 --- a/packages/viewer/src/xr/session-root.tsx +++ /dev/null @@ -1,168 +0,0 @@ -'use client' - -import { advance, useStore, useThree } from '@react-three/fiber' -import { XR, XROrigin } from '@react-three/xr' -import type { ReactNode } from 'react' -import { useEffect, useRef } from 'react' -import FrameLimiter from '../components/viewer/frame-limiter' -import { applyViewerCameraClipping, viewerCameraClipping } from '../components/viewer/viewer-camera' -import { - advanceXRFrameWithoutDesktopRender, - ownsXRFrameLoopBinding, - renderImmersiveXRFrame, - shouldPauseFrameLimiterForXR, - stopXRFrameLoop, - takeOverXRFrameLoop, - type XRFrameLoopRenderer, -} from './frame-loop' -import type { ViewerXRStore } from './store' - -function XRFrameLimiter({ - fps, - paused, - session, -}: { - fps: number - paused: boolean - session?: XRSession -}) { - return -} - -function configureWebGLXRBaseLayer(manager: { [key: string]: unknown }) { - // Three prefers XRProjectionLayer whenever a partial XRWebGLBinding exists. - // IWER exposes that binding but drives input frames from XRWebGLLayer, so - // projection-layer selection leaves the session without a base layer. - if ('_supportsLayers' in manager) manager._supportsLayers = false -} - -function XRSessionBinding({ session, store }: { session?: XRSession; store: ViewerXRStore }) { - const renderer = useThree((state) => state.gl) - const r3fXR = useThree((state) => state.xr) - const rootStore = useStore() - const activeBinding = useRef(null) - - useEffect(() => { - const manager = renderer.xr - if (!session) return - - let cancelled = false - let restoreFrameLoop: (() => void) | undefined - let resyncInputsOnNextFrame = false - const binding = Symbol('xr-session-binding') - activeBinding.current = binding - const state = rootStore.getState() - const baseCamera = state.camera - - const attachSession = async () => { - // Attach the session before starting the renderer-owned loop. IWER - // publishes input sources on its first frame; starting the loop first - // can race @react-three/xr's session synchronization and leave the - // store with a session but no controllers or hands. - r3fXR?.disconnect() - configureWebGLXRBaseLayer(manager as unknown as { [key: string]: unknown }) - const restore = await takeOverXRFrameLoop( - renderer as unknown as XRFrameLoopRenderer, - r3fXR, - (time, frame) => { - if (!frame) return - if (resyncInputsOnNextFrame) { - resyncInputsOnNextFrame = false - const xrState = store.getState() - if ( - xrState.session !== session || - (xrState.inputSourceStates.length === 0 && session.inputSources.length > 0) - ) { - // IWER publishes its initial controllers on the first immersive - // frame. Rebinding here lets the XR store consume the current - // session.inputSources even when that first change event raced - // the renderer's sessionstart event. - manager.dispatchEvent({ type: 'sessionstart' }) - } - } - const frameState = rootStore.getState() - advanceXRFrameWithoutDesktopRender(frameState, () => { - advance(time, true, frameState, frame) - }) - renderImmersiveXRFrame(renderer, frameState.scene, baseCamera) - }, - { - dpr: state.viewport.dpr, - height: state.size.height, - width: state.size.width, - }, - ) - restoreFrameLoop = restore - if (cancelled) { - // React Strict Mode can begin the replacement binding before this - // async setup settles. Only restore when this cancelled setup still - // owns the renderer; otherwise it would erase the newer frame loop. - if (ownsXRFrameLoopBinding(activeBinding.current, binding)) restore() - return - } - - if (manager.getSession() !== session) await manager.setSession(session) - session.addEventListener( - 'end', - () => stopXRFrameLoop(renderer as unknown as XRFrameLoopRenderer), - { once: true }, - ) - applyViewerCameraClipping(manager.getCamera(), true) - const clipping = viewerCameraClipping(true) - session.updateRenderState({ - baseLayer: manager.getBaseLayer() as XRWebGLLayer | undefined, - depthFar: clipping.far, - depthNear: clipping.near, - }) - - // The WebGPU renderer's WebGL backend can omit Three's sessionstart event, - // which leaves @react-three/xr unaware of controllers and hands. - if (store.getState().session !== session) { - manager.dispatchEvent({ type: 'sessionstart' }) - } - resyncInputsOnNextFrame = true - - if (cancelled) { - restore() - return - } - } - - void attachSession().catch((error: unknown) => { - console.error('[viewer] Could not attach the WebXR session', error) - void session.end().catch(() => undefined) - }) - - return () => { - cancelled = true - if (ownsXRFrameLoopBinding(activeBinding.current, binding)) restoreFrameLoop?.() - } - }, [renderer, r3fXR, rootStore, session, store]) - - return null -} - -export function ViewerXRSessionRoot({ - children, - fps, - originPosition, - paused, - session, - store, -}: { - children: ReactNode - fps: number - originPosition?: [number, number, number] - paused: boolean - session?: XRSession - store: ViewerXRStore -}) { - return ( - - - - - {children} - - ) -} diff --git a/packages/viewer/src/xr/store.test.ts b/packages/viewer/src/xr/store.test.ts deleted file mode 100644 index 5a07345724..0000000000 --- a/packages/viewer/src/xr/store.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { HAND_JOINTS, VisibleXRController, VisibleXRHand } from './input-visuals' -import { createViewerXRStore } from './store' - -describe('createViewerXRStore', () => { - test('uses local controller and hand visuals that do not depend on remote model assets', () => { - const store = createViewerXRStore() - expect(store.getState().controller).toBe(VisibleXRController) - expect(store.getState().hand).toBe(VisibleXRHand) - store.destroy() - }) - - test('covers every standard WebXR hand joint', () => { - expect(HAND_JOINTS).toHaveLength(25) - expect(new Set(HAND_JOINTS).size).toBe(HAND_JOINTS.length) - expect(HAND_JOINTS).toContain('wrist') - expect(HAND_JOINTS).toContain('index-finger-tip') - expect(HAND_JOINTS).toContain('pinky-finger-tip') - }) -}) diff --git a/packages/viewer/src/xr/store.ts b/packages/viewer/src/xr/store.ts deleted file mode 100644 index d0c78112ff..0000000000 --- a/packages/viewer/src/xr/store.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - createXRStore, - type XRStore, - type XRStoreOptions, -} from '@react-three/xr' -import { VisibleXRController, VisibleXRHand } from './input-visuals' - -export type ViewerXRStore = XRStore - -export function createViewerXRStore(options: XRStoreOptions = {}): ViewerXRStore { - return createXRStore({ - controller: VisibleXRController, - hand: VisibleXRHand, - offerSession: false, - ...options, - }) -} diff --git a/packages/viewer/src/xr/support.ts b/packages/viewer/src/xr/support.ts deleted file mode 100644 index 1c3de6170d..0000000000 --- a/packages/viewer/src/xr/support.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type ImmersiveVRSupport = 'supported' | 'unsupported' - -export async function getImmersiveVRSupport(): Promise { - if (typeof navigator === 'undefined' || !navigator.xr) return 'unsupported' - - try { - return (await navigator.xr.isSessionSupported('immersive-vr')) ? 'supported' : 'unsupported' - } catch { - return 'unsupported' - } -} diff --git a/patches/iwer@2.3.0.patch b/patches/iwer@2.3.0.patch deleted file mode 100644 index 9e6cfd1195..0000000000 --- a/patches/iwer@2.3.0.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/lib/device/XRTrackedInput.js b/lib/device/XRTrackedInput.js -index c767f8d90ce42049d6c5db5320ca78f6766ee162..1cf696736f6e7865d42e6b84c7be177c49d765d6 100644 ---- a/lib/device/XRTrackedInput.js -+++ b/lib/device/XRTrackedInput.js -@@ -67,10 +67,6 @@ export class XRTrackedInput { - if (button[P_GAMEPAD].eventTrigger != null) { - if (button[P_GAMEPAD].lastFrameValue === 0 && - button[P_GAMEPAD].value > 0) { -- session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, { -- frame, -- inputSource: this[P_TRACKED_INPUT].inputSource, -- })); - session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger + 'start', { - frame, - inputSource: this[P_TRACKED_INPUT].inputSource, -@@ -82,6 +78,10 @@ export class XRTrackedInput { - frame, - inputSource: this[P_TRACKED_INPUT].inputSource, - })); -+ session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, { -+ frame, -+ inputSource: this[P_TRACKED_INPUT].inputSource, -+ })); - } - } - } diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index f45357f4bf..d4ed804517 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -18,7 +18,6 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [capture-runtime](capture-runtime.md) | Open capture protocol, host source boundary, static/live viewer layers, and stream extension | -| [xr](xr.md) | WebXR ownership, renderer switching, session lifecycle, local emulation, and folder structure | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [selection-groups](selection-groups.md) | Session multi-select groups (Ctrl/Cmd+G), expand-on-click, how they differ from collections | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | diff --git a/wiki/architecture/xr.md b/wiki/architecture/xr.md deleted file mode 100644 index 29725da822..0000000000 --- a/wiki/architecture/xr.md +++ /dev/null @@ -1,117 +0,0 @@ -# WebXR - -WebXR is an optional presentation path for the existing scene. It does not add XR data to the scene graph and therefore has no code in `packages/core`. - -## Folder structure - -```text -packages/viewer/src/xr/ -├── god-mode/ # Encapsulates God-scale scene transforms, controller grips, palm grabs, and reset state. -├── human-mode/ # Owns first-person movement, snap turn, hand locomotion, comfort, and scene collision. -├── mode-switching/ # Coordinates God/Human transitions without changing persisted scene data. -├── presentation-context.tsx # Tells renderers when the scene is using direct immersive presentation. -├── session-root.tsx # Connects the R3F scene to an XR store and hands frame timing to the headset. -├── store.ts # Creates the reusable XR session store with default hand/controller rendering. -└── support.ts # Performs the safe immersive-vr browser capability check. - -apps/editor/components/xr/ -├── wand-panel/ # Left-hand three-face Build, Paint, and selection-aware Settings UI. -├── xr-editor-input-bridge.tsx # Adapts controller trigger and hand pinch rays to the editor's existing pointer/event pipeline. -├── xr-emulator-test-harness.tsx # Exposes development-only, event-observable controller and hand scenarios. -├── xr-preview-environment.tsx # Dedicated scene loader and launch surface for XR testing. -└── xr-runtime.tsx # Owns XR runtime state, session requests, and the Viewer XR configuration. - -apps/editor/lib/xr/ -├── editor-input.ts # Pure controller/hand source selection and XR button edge detection. -├── emulator-ray.ts # Resolves deterministic controller/hand poses for emulator targets. -├── emulator.ts # Installs the Quest 3 IWER emulator only in local development when native XR is absent. -├── settings.ts # Resolves registry settings for selected nodes and pre-placement tool defaults. -├── wand-panel-settings.ts # Holds session-only wand presentation preferences such as panel scale. -└── preview-window.ts # Opens or focuses the standalone XR testing window. - -apps/editor/lib/build-palette.ts # Shared palette definitions and activators used by desktop and XR build surfaces. - -apps/editor/app/xr/ -├── page.tsx # Tests the local editor scene. -└── scene/[id]/page.tsx # Tests a persisted scene by id. -``` - -## Ownership - -- `packages/viewer` owns renderer and session integration because those are generic presentation concerns. Its public API is `createViewerXRStore()`, `getImmersiveVRSupport()`, and the optional `Viewer.xr` configuration. `Viewer.xr.inputSourceOverlay` is a presentation-only extension point rendered inside each controller/hand context. -- `packages/viewer/src/xr/god-mode` owns the reusable God-scale interaction module. It transforms a presentation-only scene root and never writes scene graph data. -- `packages/viewer/src/xr/human-mode` owns reusable first-person XR input and collision. It operates on the XR origin and rendered mesh BVHs, not editor tools or scene graph state. -- `packages/viewer/src/xr/mode-switching` owns the presentation-only transition between God and Human scale and restores the prior God transform when switching back. -- `packages/editor` only passes the host-provided XR configuration through to its main viewer canvas. -- `apps/editor` owns the dedicated XR routes, toolbar button, development emulator, tracked-input adapter, and spatial editor panels. The panels select tools through the shared build palette, materials through the core material library, and settings through registry parametrics; they do not duplicate placement or geometry rules. -- `packages/core` remains unchanged because entering XR does not change persisted scene data. - -## Renderer policy - -Desktop mode keeps the existing automatic renderer selection: WebGPU is preferred and WebGL2 is the fallback. - -XR mode runs only under `/xr` or `/xr/scene/[id]` and mounts the canvas with `WebGPURenderer({ forceWebGL: true, multiview: false })`. The editor keeps its existing desktop renderer and does not remount when XR begins. The XR renderer remains Three.js `WebGPURenderer`, but its backend is WebGL2. This gives WebXR a predictable WebGL context and isolates emulator state from the editing session. Three.js `0.185.1` is pinned at the workspace root. Multiview remains disabled for the initial compatibility baseline and can be enabled after validation on physical headsets. - -The icon-only VR button sits beside Walkthrough and Preview in the editor toolbar. Its click opens or focuses a named XR testing window. That window has its own explicit session-start button because native WebXR requires user activation in the same browsing context that requests the immersive session. - -Before opening the testing window, the toolbar snapshots the editor's current in-memory scene into a dedicated XR preview key and marks the route to consume that snapshot. This keeps the immersive scene aligned with unsaved or debounce-pending edits instead of depending on the last autosave or API response. Direct visits to a persisted scene XR URL still load that scene through the API. - -The TSL post-processing pipeline is unmounted while XR mode is configured. XR uses one dedicated direct-render driver after scene systems run, avoiding SSGI, denoise, ink, and outline passes that have not been validated for stereo XR rendering. The driver updates Three's stereo union camera before drawing and prevents a second automatic camera update during that draw. - -The desktop frame limiter pauses while an immersive session is active. React Three Fiber then renders from the WebXR animation loop at the headset's cadence and receives the current `XRFrame`. - -The standalone XR route mounts the editor's existing selection manager, grid, node handles, and `ToolManager` as children of ``. Desktop camera controls, labels, post-processing, and thumbnail capture remain unmounted. Ending or unmounting XR also ends its active session. - -Controller trigger and tracked-hand pinch use the pointer implementation supplied by `@react-three/xr`. The app-level XR input bridge independently intersects the controller/hand target ray with the existing editor grid and emits the same `grid:move`, `grid:pointerdown`, `grid:pointerup`, and `grid:click` events consumed on desktop. Holding trigger or pinch on an already-selected movable node enters the existing press-drag move path; release is forwarded to its existing commit-on-release listener. The right controller B button emits the existing `tool:cancel` event. No XR-specific scene mutation or placement algorithm exists. - -Wall-hosted tools receive the existing `wall:enter`, `wall:move`, and `wall:click` events. The wall collision mesh stays render-active with color and depth writes disabled; setting the mesh or its material invisible removes it from the spatial-pointer traversal even though a direct Three.js raycast can still report it. This keeps door and window placement on the same host-resolution path as desktop input. - -Editor selection and manipulation use the ray pointer exclusively; near-field grab and touch pointers do not compete for the same scene node. Ray filtering accepts both direct R3F handlers and handlers inherited from a rendered ancestor. This is required for imported GLB items, elevators, and other renderers whose event handlers live on a wrapper while the raycastable meshes are nested below it. - -The left controller grip or left middle-finger metacarpal carries the three-face wand panel copied from the WebXR Home attachment geometry. Its labels use canvas textures and its borders use standard Three.js lines because Drei's Troika text and fat-line shader materials are incompatible with the XR renderer's node-material path. Ring arrows rotate between Build, Paint, and Settings. Build is paginated and exposes nested Roof and MEP pages using the same icons and activation functions as the desktop Build tab. Paint is always present and pages through the live core material library. Settings follows the single selected node and derives number, boolean, enum, vector, and read-only fallback rows from `nodeRegistry`; writes use the same derive/reconcile commit helper as the desktop parametric inspector. The input bridge suppresses grid authoring while a target ray intersects the wand, so pressing a panel control cannot also place scene geometry. - -Every spatial control has a stable `xr-*` object name. Select is pinned as the first Build tile on every main, Roof, and MEP page so every tool has an immediate spatial escape path. The Settings face uses one flattened sequence for visible registry fields, vector axes, actions, and live tool-hint chips, so pagination cannot hide a second independent control list. A selected node has priority; otherwise the active build tool is shown with registry defaults merged with editor tool defaults, and edits are saved before placement. Tool-hint chips use the same store and cycle action as desktop helpers and keyboard shortcuts, including cabinet-versus-island placement. Unsupported custom DOM editors are labelled as desktop-only instead of pretending to be editable in XR. - -When no item or build tool owns Settings, the face exposes Undo and Redo through the shared editor history controller, plus the presentation-only God-view reset and wand scale. The Wall Snap control edits the same per-context Grid, Lines, Angles, and Off state consumed by desktop wall drafting. This preserves standalone Zundo and host-provided collaborative history behavior, disables history or view jumps while an interaction scope is active, and keeps panel sizing out of persisted scene data. - -The Paint face uses the shared material library and material-paint state. Its scope control remembers the last paintable surface while the ray moves from the scene to the wrist panel, because leaving the scene clears the live hover before the spatial button is pressed. The chosen scope is still committed through the shared registry paint capability when the ray returns to the surface. - -Terrain mode keeps the desktop terrain model and undo boundary: an XR select press freezes the field snapshot, controller movement or hand motion advances the same saturating brush stroke, and release commits one scene-history step. The Settings face becomes the terrain control surface while the mode is active, exposing verb, brush dimensions, flatten sampling, lot leveling, and reset without introducing a second terrain state. - -MEP tools consume those live tool defaults when previewing and committing. Duct terminals use grid events for floor placement, wall events for wall placement, and spatial node rays for ceiling placement, so controller triggers and hand pinches follow the same placement contracts as desktop input. - -## Local testing - -Run: - -```bash -bun dev:xr -``` - -This starts the Next.js editor on all interfaces with its development HTTPS certificate. Open a scene and click the VR headset icon beside Walkthrough and Preview. Clicking the active icon exits VR. - -- On a desktop browser without native immersive WebXR, the app dynamically imports IWER and emulates a Meta Quest 3. The emulator is registered once across development hot reloads. -- The IWER DevUI is registered with the emulated device, so entering VR shows headset and controller transforms, buttons, sticks, reset, play mode, and session-exit controls over the XR canvas. -- The standalone test environment explicitly mounts the DevUI canvas and controls while an emulated session is active. This covers Three's forced-WebGL backend, which can initialize the XR session without invoking IWER's normal base-layer attachment callback. -- Controllers and hands use the same `DefaultXRController` and `DefaultXRHand` implementations as WebXR Home, with the editor-owned wand injected through the generic viewer input overlay. -- The XR camera uses the reference project's `0.001–10000` clipping range and an explicit `XROrigin`. The standalone preview starts in God mode at the reference project's elevated `[0, 4.5, 8]` origin. -- God mode wraps only rendered scene geometry in `xr-player-scene-root`; lights, cameras, controller/hand models, and the XR origin remain outside that transform. One grip pans the scene, two grips pan/rotate/scale it, and a held three-finger curl exposes the same grab interaction for tracked hands. -- Reset restores the scene root to identity and the XR origin to the default God-view pose. These are presentation transforms and are never persisted to `packages/core`. -- Human mode restores the scene to world scale. The left controller stick moves relative to head direction, the right stick snap-turns, and movement is resolved through a player capsule against the rendered scene's BVHs. -- With hand tracking, pinching inside the left wrist zone drives locomotion and pinching inside the right wrist zone drives turning. Movement and turns use the same comfort vignette and haptic feedback behavior as WebXR Home. -- Press the left controller Y button or use the mode button in the test environment to switch between God and Human mode. Standalone viewer integrations can also hold both tracked thumb tips together for 0.8 seconds. The editor disables that proximity gesture because it conflicts with precise hand interaction on the wand; use **Settings → XR scale** instead. Returning to God mode restores the scene transform captured before entering Human mode. -- XR supplies the theme's neutral base background because the desktop sky gradient belongs to the post-processing pipeline. The zenith colour is not flattened across the immersive view. -- The site's presentation-only horizon disc is suppressed in immersive XR because its fade depends on the desktop post-processing backdrop. The real site ground, slabs, terrain, and scene geometry remain visible. -- The Synthetic Environment Module is not registered for VR testing because it adds its own floor grid and environment canvas. Add it only when an AR/MR feature needs synthetic planes, meshes, depth, or hit testing. -- On a browser or headset with native immersive WebXR, the emulator is not installed. -- Production builds never load or install IWER. -- Development sessions expose `__pascalXRTestHarness`. It aims the emulated right controller or hand at stable spatial-control names and drives the actual IWER trigger/pinch transition. A click succeeds only after the target receives its R3F click event; snapshots report hover, delivered pointer and grid events, editor mode/tool/scope, selection, and scene counts. `clickLevelPoint` drives the existing grid event pipeline at a level-local plan coordinate, while `placeToolOnGrid` verifies tool activation, delivered points, newly created node IDs, and cancellation back to Select. `placeToolOnNode` additionally verifies delivery to a host surface and checks that the committed child references that host. This makes panel, selection, placement, and manipulation checks observable rather than timing-only smoke tests. -- A physical headset must trust the development certificate when connecting over the local network. `localhost` testing can use the normal development command, but HTTPS is the reliable path for another device. - -The neighboring `WebXR Home` project uses `@iwsdk/vite-plugin-dev`. That plugin is intentionally not copied because this app runs on Next.js rather than Vite. Direct IWER initialization provides the equivalent local emulator without adding a second app runtime or IWSDK scene engine. - -The toolbar remains icon-only. Hovering the headset icon reports `Enter VR with IWER emulator` when the emulated runtime is active. After entry, use the DevUI panels to connect or move controllers and the top controls to move or reset the headset. No Chrome extension is required for this development path. - -## Current scope - -The XR preview renders the existing scene with default controller and hand models, God-scale navigation, Human-mode locomotion, the existing 3D authoring tools, and the left-hand three-face editor wand. Scene graphs are normalized through each registered node schema before they reach renderers, so older snapshots receive required defaults such as site polygons and building transforms. The active desktop phase/mode/tool preference is rehydrated in the standalone XR window. Trigger or hand pinch can draw and place through the existing grid and node event pipeline; selecting a movable node and holding the trigger/pinch routes through its existing mover. Custom DOM-only inspector editors remain desktop-only and appear as read-only fallback rows in the spatial Settings face. XR-specific scene mutations remain out of scope. From f9843713bfe917014cd3ca35ce7ddfab70eaa298 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 9 Sep 2026 11:07:35 +0530 Subject: [PATCH 06/19] chore: remove WebXR support --- apps/editor/.gitignore | 2 - apps/editor/components/build-tab.tsx | 248 +++++++++++++++-- apps/editor/components/scene-loader.tsx | 110 ++++---- apps/editor/components/viewer-toolbar.tsx | 3 +- apps/editor/lib/bootstrap.ts | 10 +- apps/editor/lib/build-palette.test.ts | 57 ---- apps/editor/lib/build-palette.ts | 253 ------------------ apps/editor/lib/build-tab-state.ts | 26 -- apps/editor/next.config.ts | 17 +- apps/editor/tsconfig.json | 5 +- bun.lock | 74 +---- package.json | 3 - .../editor/editor-layout-mobile.tsx | 45 ++-- .../components/editor/editor-layout-v2.tsx | 30 +-- .../editor/src/components/editor/grid.tsx | 3 - .../components/editor/group-rotate-handle.tsx | 47 +--- .../editor/handles/handle-arrow.tsx | 157 +++-------- .../editor/handles/use-handle-drag.ts | 40 +-- .../editor/src/components/editor/index.tsx | 61 +---- .../editor/wall-move-side-handles.tsx | 38 +-- .../ui/panels/parametric-inspector.tsx | 18 +- packages/editor/src/hooks/use-keyboard.ts | 20 +- packages/editor/src/index.tsx | 23 +- .../editor/src/lib/parametric-node-update.ts | 37 --- packages/editor/src/lib/scene.test.ts | 99 ------- packages/editor/src/lib/scene.ts | 24 +- .../src/lib/spatial-pointer-input.test.ts | 50 ---- .../editor/src/lib/spatial-pointer-input.ts | 56 ---- .../src/components/viewer/frame-limiter.tsx | 6 - .../viewer/src/components/viewer/index.tsx | 250 ++++------------- .../src/components/viewer/post-processing.tsx | 4 - .../components/viewer/viewer-camera.test.ts | 57 ---- .../src/components/viewer/viewer-camera.tsx | 71 +---- packages/viewer/src/index.ts | 6 +- .../src/lib/renderer-capability.test.tsx | 17 -- .../viewer/src/lib/renderer-capability.ts | 4 +- patches/three@0.185.1.patch | 39 --- 37 files changed, 483 insertions(+), 1527 deletions(-) delete mode 100644 apps/editor/lib/build-palette.test.ts delete mode 100644 apps/editor/lib/build-palette.ts delete mode 100644 packages/editor/src/lib/parametric-node-update.ts delete mode 100644 packages/editor/src/lib/scene.test.ts delete mode 100644 packages/editor/src/lib/spatial-pointer-input.test.ts delete mode 100644 packages/editor/src/lib/spatial-pointer-input.ts delete mode 100644 packages/viewer/src/components/viewer/viewer-camera.test.ts delete mode 100644 patches/three@0.185.1.patch diff --git a/apps/editor/.gitignore b/apps/editor/.gitignore index 00e86211d1..684fd2301d 100644 --- a/apps/editor/.gitignore +++ b/apps/editor/.gitignore @@ -35,5 +35,3 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env*.local - -certificates/ diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 353b3022e7..508deb3225 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,7 +1,16 @@ 'use client' -import { RoofType as RoofTypeSchema, useRegistryVersion } from '@pascal-app/core' import { + nodeRegistry, + type RoofType, + RoofType as RoofTypeSchema, + useRegistryVersion, +} from '@pascal-app/core' +import { + CATALOG_ITEMS, + type FloorplanMode, + getFloorplanNodeExtension, + isFloorplanToolAvailableInMode, MaterialPaintPanel, TerrainSculptPanel, ToolOptionsPanel, @@ -10,6 +19,7 @@ import { useFloorplanMode, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' +import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { @@ -18,27 +28,224 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/toolbar-tooltip' -import { - activateBuildTool, - activateModularCabinetTool, - activatePaintMode, - activateRoofFeatureTool, - activateRoofType, - activateTerrainSculptMode, - BASE_BUILD_TYPES, - type BuildType, - collectBuildTypes, - collectRoofFeatures, - MEP_ITEMS, - MEP_TOOL_KINDS, - type MepItem, - MODULAR_CABINET_ICON, -} from '@/lib/build-palette' import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' import { cn } from '@/lib/utils' +/** + * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" + * group tile — its own sub-grid, like Roof's "Features". + */ +type MepToolKind = + | 'duct-segment' + | 'duct-fitting' + | 'duct-terminal' + | 'hvac-equipment' + | 'lineset' + | 'liquid-line' + | 'pipe-segment' + | 'pipe-fitting' + | 'pipe-trap' + +type BuildType = { + /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */ + id: string + label: string + /** Raster asset tile (legacy Build sidebar artwork). */ + iconSrc: string + /** Present for structure-tool types (absent for paint mode and the MEP group). */ + kind?: string + paletteOrder?: number + /** Non-placement special mode. */ + mode?: 'material-paint' | 'terrain-sculpt' +} + +type MepItem = { + /** Selection id — equals `kind`. */ + id: string + label: string + iconSrc: string + kind: MepToolKind +} + +// Same icons + ordering as the community Build sidebar, minus presets. +const BASE_BUILD_TYPES: BuildType[] = [ + { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, + { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, + { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, + { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, + { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, + { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, + { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, + { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, + { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, + { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, + { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, + { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, + // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). + { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, + { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, + { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, +] + const subscribeToClientMount = () => () => {} +function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { + const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) + const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ + ...type, + paletteOrder: + nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, + })) + for (const [kind, definition] of nodeRegistry.entries()) { + const presentation = definition.presentation + const extension = getFloorplanNodeExtension(definition) + if ( + baseKinds.has(kind) || + definition.presentation?.paletteGroup === 'roof-features' || + !extension?.tool || + !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || + !presentation || + presentation.hidden || + presentation.paletteSection !== 'structure' + ) { + continue + } + tools.push({ + id: kind, + kind, + label: presentation.label, + iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', + paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, + }) + } + tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) + return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] +} + +// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP +// tools had in the community Build sidebar. +const MEP_ITEMS: MepItem[] = [ + { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, + { + id: 'duct-terminal', + label: 'Register', + iconSrc: '/icons/registers.webp', + kind: 'duct-terminal', + }, + { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, + { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, + { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, + { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, +] + +const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') +const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' + +/** + * Activate a raw structure draw/cursor tool. Mirrors the editor's own + * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). + */ +function activateBuildTool(kind: string): void { + const ed = useEditor.getState() + const definition = nodeRegistry.get(kind) + const extension = getFloorplanNodeExtension(definition) + if ( + !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) + ) { + useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) + return + } + const preferredView = extension?.preferredView + if (preferredView) ed.setViewMode(preferredView) + ed.setPhase('structure') + ed.setStructureLayer('elements') + ed.setCatalogCategory(null) + ed.setToolDefaults(kind, null) + ed.setMode('build') + ed.setTool(kind) +} + +function activateModularCabinetTool(): void { + const ed = useEditor.getState() + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) + ed.setPhase('structure') + ed.setStructureLayer('elements') + ed.setCatalogCategory(null) + ed.setMode('build') + ed.setTool('cabinet') +} + +/** Enter material-paint mode — the Build tab's "Painting" category. */ +function activatePaintMode(): void { + const ed = useEditor.getState() + ed.setPhase('structure') + ed.setStructureLayer('elements') + ed.setMode('material-paint') +} + +/** + * Enter terrain-sculpt mode — the Build tab's "Terrain" category. No `setPhase`: + * `setMode` moves to the site phase itself, since sculpting is a site-phase mode. + */ +function activateTerrainSculptMode(): void { + useEditor.getState().setMode('terrain-sculpt') +} + +type RoofFeature = { + id: string + label: string + iconSrc: string + kind?: string +} + +const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' + +function collectRoofFeatures(): RoofFeature[] { + const features: RoofFeature[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if ( + def.capabilities.roofAccessory === undefined && + def.presentation?.paletteGroup !== 'roof-features' + ) { + continue + } + if (def.capabilities.wallOpeningPlacement) continue + const icon = def.presentation?.icon + features.push({ + id: kind, + kind, + label: def.presentation?.label ?? kind, + iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, + }) + } + return features +} + +/** + * Roof accessories and extensions surfaced under the Roof tile. Unlike the + * community editor these aren't DB presets — each is a registry kind, either + * carrying `capabilities.roofAccessory` or explicitly classified as a roof + * extension. They are enumerated at render time because the registry is + * populated during app bootstrap. Label + icon come from `presentation`; + * non-url icons fall back to the roof icon. + */ +function activateRoofFeatureTool(feature: RoofFeature): void { + const ed = useEditor.getState() + ed.setPhase('structure') + ed.setStructureLayer('elements') + ed.setCatalogCategory(null) + ed.setMode('build') + if (feature.kind) ed.setTool(feature.kind) +} + +function activateRoofType(roofType: RoofType): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType }) +} + /** * Build tab for the open-source standalone editor — a preset-less replica of * the community Build sidebar. Clicking a type activates its raw tool, drawn @@ -47,6 +254,13 @@ const subscribeToClientMount = () => () => {} */ // MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid) // is what the user is working in. +const MEP_TOOL_KINDS = new Set([ + ...MEP_ITEMS.map((item) => item.kind), + 'duct-fitting', + 'pipe-fitting', + 'pipe-trap', +]) + export function BuildTab() { const [mepOpen, setMepOpen] = useState(false) const activeTool = useEditor((s) => s.tool) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index a9df71ac96..d7538ead71 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -108,20 +108,6 @@ function isLightPreviewQuery(searchParams: URLSearchParams): boolean { return disable.split(',').some((p) => p.trim() === 'postFx') } -function sceneUrl( - sceneId: string, - searchParams: URLSearchParams, - update: Record, -) { - const next = new URLSearchParams(searchParams) - for (const [key, value] of Object.entries(update)) { - if (value == null) next.delete(key) - else next.set(key, value) - } - const query = next.toString() - return `/scene/${sceneId}${query ? `?${query}` : ''}` -} - export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const router = useRouter() const searchParams = useSearchParams() @@ -256,63 +242,57 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { return (
- <> - {conflict && ( -
-

Another session saved first — refresh?

-

- Your changes haven't been saved. Reload to pick up the latest version. -

-
- - -
-
- )} - {saveError && !conflict && ( -
-

{saveError}

-
- )} -
+ {conflict && ( +
+

Another session saved first — refresh?

+

+ Your changes haven't been saved. Reload to pick up the latest version. +

+
- setConflict(false)} + type="button" > - All scenes - + Dismiss +
- +
+ )} + {saveError && !conflict && ( +
+

{saveError}

+
+ )} +
+ + + All scenes + +
@@ -704,7 +704,6 @@ export function CommunityViewerToolbarRight({ pluginActions }: { pluginActions?:
- {pluginActions}
) diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 7dc9474800..6708f1305c 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -13,7 +13,9 @@ import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones' import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape' import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' -// Each module evaluation loads builtins once; development reloads replace stale definitions. +// Idempotency guards: HMR can reload this module, but `registerNode` +// throws on duplicate kinds. Flags live in the module closure so they +// reset on a hard reload but survive within a session. let builtinsLoaded = false let externalsKickedOff = false @@ -39,7 +41,10 @@ function loadBuiltinsSync(): void { if (builtinsLoaded) return builtinsLoaded = true for (const def of builtinPlugin.nodes ?? []) { - if (nodeRegistry.has((def as AnyNodeDefinition).kind) && !isDev()) continue + // Skip kinds the registry already has. The module-closure flag + // above resets on HMR, but the registry singleton (in @pascal-app/core) + // persists — without this guard we'd throw on the first duplicate. + if (nodeRegistry.has((def as AnyNodeDefinition).kind)) continue registerNode(def as AnyNodeDefinition) } @@ -95,5 +100,6 @@ registerEditorHostPanel({ ...streetscapeHostPanel, creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, }) + loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/lib/build-palette.test.ts b/apps/editor/lib/build-palette.test.ts deleted file mode 100644 index 19e5fd1204..0000000000 --- a/apps/editor/lib/build-palette.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { afterEach, describe, expect, test } from 'bun:test' -import { emitter } from '@pascal-app/core' -import { useEditor } from '@pascal-app/editor' -import { - activateBuildTool, - activateSelectMode, - collectXRBuildPaletteManifest, - XR_MEP_ITEMS, -} from './build-palette' - -describe('build palette actions', () => { - afterEach(() => { - useEditor.getState().setMode('select') - }) - - test('cancels the active tool before returning to Select mode', () => { - const editor = useEditor.getState() - editor.setPhase('structure') - editor.setMode('build') - editor.setTool('door') - let modeWhenCancelled: string | null = null - const onCancel = () => { - modeWhenCancelled = useEditor.getState().mode - } - emitter.on('tool:cancel', onCancel) - - try { - activateSelectMode() - } finally { - emitter.off('tool:cancel', onCancel) - } - - expect(modeWhenCancelled).toBe('build') - expect(useEditor.getState().mode).toBe('select') - expect(useEditor.getState().tool).toBeNull() - }) - - test('exposes every XR submenu entry once with Select first', () => { - const manifest = collectXRBuildPaletteManifest('expert') - - for (const entries of Object.values(manifest)) { - expect(entries[0]).toBe('select') - expect(new Set(entries).size).toBe(entries.length) - } - expect(manifest.mep.slice(1)).toEqual(XR_MEP_ITEMS.map((entry) => entry.id)) - }) - - test('clears selection and exposes the chosen tool defaults', () => { - useEditor.getState().setToolDefaults('wall', { height: 9 }) - - activateBuildTool('wall') - - expect(useEditor.getState().mode).toBe('build') - expect(useEditor.getState().tool).toBe('wall') - expect(useEditor.getState().toolDefaults.wall).toBeUndefined() - }) -}) diff --git a/apps/editor/lib/build-palette.ts b/apps/editor/lib/build-palette.ts deleted file mode 100644 index b042c610af..0000000000 --- a/apps/editor/lib/build-palette.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { emitter, nodeRegistry, type RoofType } from '@pascal-app/core' -import { - CATALOG_ITEMS, - type FloorplanMode, - getFloorplanNodeExtension, - isFloorplanToolAvailableInMode, - useEditor, - useFloorplanMode, -} from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { - getRoofFootprintSource, - ROOF_TYPE_OPTIONS, - type RoofFootprintSource, -} from '@/lib/build-tab-state' - -export type MepToolKind = - | 'duct-segment' - | 'duct-fitting' - | 'duct-terminal' - | 'hvac-equipment' - | 'lineset' - | 'liquid-line' - | 'pipe-segment' - | 'pipe-fitting' - | 'pipe-trap' - -export type BuildType = { - id: string - label: string - iconSrc: string - kind?: string - paletteOrder?: number - mode?: 'material-paint' | 'terrain-sculpt' -} - -export type MepItem = { - id: string - label: string - iconSrc: string - kind: MepToolKind -} - -export type RoofFeature = { - id: string - label: string - iconSrc: string - kind?: string -} - -export const BASE_BUILD_TYPES: BuildType[] = [ - { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, - { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, - { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, - { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, - { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, - { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, - { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, - { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, - { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, - { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, - { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, - { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, - { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, - { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, - { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, - { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, -] - -export const MEP_ITEMS: MepItem[] = [ - { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, - { - id: 'duct-terminal', - label: 'Register', - iconSrc: '/icons/registers.webp', - kind: 'duct-terminal', - }, - { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, - { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, - { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, - { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, -] - -export const XR_MEP_ITEMS: MepItem[] = [ - ...MEP_ITEMS, - { - id: 'duct-fitting', - label: 'Duct Fitting', - iconSrc: '/icons/duct-fitting.webp', - kind: 'duct-fitting', - }, - { - id: 'pipe-fitting', - label: 'Pipe Fitting', - iconSrc: '/icons/duct-fitting.webp', - kind: 'pipe-fitting', - }, - { - id: 'pipe-trap', - label: 'Pipe Trap', - iconSrc: '/icons/dwv-pipes.webp', - kind: 'pipe-trap', - }, -] - -export const MEP_TOOL_KINDS = new Set([ - ...MEP_ITEMS.map((item) => item.kind), - 'duct-fitting', - 'pipe-fitting', - 'pipe-trap', -]) - -const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') -export const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' - -export function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { - const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) - const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ - ...type, - paletteOrder: - nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, - })) - for (const [kind, definition] of nodeRegistry.entries()) { - const presentation = definition.presentation - const extension = getFloorplanNodeExtension(definition) - if ( - baseKinds.has(kind) || - presentation?.paletteGroup === 'roof-features' || - !extension?.tool || - !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || - !presentation || - presentation.hidden || - presentation.paletteSection !== 'structure' - ) { - continue - } - tools.push({ - id: kind, - kind, - label: presentation.label, - iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', - paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, - }) - } - tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) - return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] -} - -export function collectXRBuildPaletteManifest(floorplanMode: FloorplanMode) { - return { - main: ['select', ...collectBuildTypes(floorplanMode).map((entry) => entry.id)], - mep: ['select', ...XR_MEP_ITEMS.map((entry) => entry.id)], - roof: [ - 'select', - ...ROOF_TYPE_OPTIONS.map((entry) => `roof-${entry.value}`), - ...collectRoofFeatures().map((entry) => entry.id), - ], - } -} - -export function activateBuildTool(kind: string): void { - const editor = useEditor.getState() - const definition = nodeRegistry.get(kind) - const extension = getFloorplanNodeExtension(definition) - if ( - !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) - ) { - useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) - return - } - if (extension?.preferredView) editor.setViewMode(extension.preferredView) - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - editor.setPhase('structure') - editor.setStructureLayer('elements') - editor.setCatalogCategory(null) - editor.setToolDefaults(kind, null) - editor.setMode('build') - editor.setTool(kind) -} - -export function activateSelectMode(): void { - emitter.emit('tool:cancel') - useEditor.getState().setMode('select') -} - -export function activateModularCabinetTool(): void { - const editor = useEditor.getState() - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - if (MODULAR_CABINET_CATALOG_ITEM) editor.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) - editor.setPhase('structure') - editor.setStructureLayer('elements') - editor.setCatalogCategory(null) - editor.setMode('build') - editor.setTool('cabinet') -} - -export function activatePaintMode(): void { - const editor = useEditor.getState() - editor.setPhase('structure') - editor.setStructureLayer('elements') - editor.setMode('material-paint') -} - -export function activateTerrainSculptMode(): void { - useEditor.getState().setMode('terrain-sculpt') -} - -export function collectRoofFeatures(): RoofFeature[] { - const features: RoofFeature[] = [] - for (const [kind, definition] of nodeRegistry.entries()) { - if ( - definition.capabilities.roofAccessory === undefined && - definition.presentation?.paletteGroup !== 'roof-features' - ) { - continue - } - if (definition.capabilities.wallOpeningPlacement) continue - const icon = definition.presentation?.icon - features.push({ - id: kind, - kind, - label: definition.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : '/icons/roof.webp', - }) - } - return features -} - -export function activateRoofFeatureTool(feature: RoofFeature): void { - const editor = useEditor.getState() - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - editor.setPhase('structure') - editor.setStructureLayer('elements') - editor.setCatalogCategory(null) - editor.setMode('build') - if (feature.kind) editor.setTool(feature.kind) -} - -export function activateRoofType(roofType: RoofType): void { - const editor = useEditor.getState() - if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') - const footprintSource = getRoofFootprintSource( - roofType, - editor.toolDefaults.roof?.footprintSource, - ) - editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType, footprintSource }) -} - -export function activateRoofFootprintSource(footprintSource: RoofFootprintSource): void { - const editor = useEditor.getState() - if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') - editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, footprintSource }) -} diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts index 855867f188..478f4e559f 100644 --- a/apps/editor/lib/build-tab-state.ts +++ b/apps/editor/lib/build-tab-state.ts @@ -5,32 +5,6 @@ export type RoofFeatureIdentity = { kind?: string } -const ROOF_FOOTPRINT_SOURCES = [ - { label: 'Room', value: 'room' }, - { label: 'Wall', value: 'walls' }, - { label: 'Draw', value: 'draw' }, -] as const - -export type RoofFootprintSource = (typeof ROOF_FOOTPRINT_SOURCES)[number]['value'] - -const CONICAL_ROOF_FOOTPRINT_SOURCES = [ROOF_FOOTPRINT_SOURCES[1]] as const - -const STANDARD_ROOF_FOOTPRINT_SOURCES = [ - ROOF_FOOTPRINT_SOURCES[2], - ROOF_FOOTPRINT_SOURCES[0], -] as const - -export function getRoofFootprintSources(roofType: RoofType) { - return roofType === 'conical' ? CONICAL_ROOF_FOOTPRINT_SOURCES : STANDARD_ROOF_FOOTPRINT_SOURCES -} - -export function getRoofFootprintSource(roofType: RoofType, value: unknown): RoofFootprintSource { - const sources = getRoofFootprintSources(roofType) - return sources.some((source) => source.value === value) - ? (value as RoofFootprintSource) - : sources[0].value -} - export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [ { label: 'Hip', value: 'hip' }, { label: 'Gable', value: 'gable' }, diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index c2250ef26b..48578416b9 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -6,7 +6,6 @@ const appDirectory = path.dirname(fileURLToPath(import.meta.url)) const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1' const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.0.102'], ...(portableBuild ? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') } : {}), @@ -40,19 +39,11 @@ const nextConfig: NextConfig = { '@dgreenheck/ez-tree', ], turbopack: { - // Include the editor and locally linked sibling plugin without watching the whole home folder. - root: path.join(appDirectory, '../../..'), resolveAlias: { - '@pascal-app/core': '../../packages/core/src/index.ts', - '@pascal-app/editor': '../../packages/editor/src/index.tsx', - '@pascal-app/viewer': '../../packages/viewer/src/index.ts', - react: '../../node_modules/react', - three: '../../node_modules/three', - // TSL and the renderer must share one module-level shader stack. - 'three/webgpu': '../../node_modules/three/build/three.webgpu.js', - 'three/tsl': '../../node_modules/three/build/three.tsl.js', - '@react-three/fiber': '../../node_modules/@react-three/fiber', - '@react-three/drei': '../../node_modules/@react-three/drei', + react: './node_modules/react', + three: './node_modules/three', + '@react-three/fiber': './node_modules/@react-three/fiber', + '@react-three/drei': './node_modules/@react-three/drei', }, }, experimental: { diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index 18b620d198..70924e110d 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -7,10 +7,7 @@ } ], "paths": { - "@/*": ["./*"], - "@pascal-app/core": ["../../packages/core/src/index.ts"], - "@pascal-app/editor": ["../../packages/editor/src/index.tsx"], - "@pascal-app/viewer": ["../../packages/viewer/src/index.ts"], + "@/*": ["./*"] } }, "include": [ diff --git a/bun.lock b/bun.lock index 6ef2616c54..a7c5c711c9 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", - "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "geist": "^1.7.0", @@ -56,14 +55,12 @@ "zod": ">=4.5.4 <4.6", }, "devDependencies": { - "@iwer/devui": "2.3.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", - "iwer": "2.3.0", "react-grab": "^0.1.50", "react-scan": "^0.5.7", "tw-animate-css": "^1.4.0", @@ -352,7 +349,6 @@ "name": "@pascal-app/viewer", "version": "1.0.0-beta.5", "dependencies": { - "@react-three/xr": "^6.6.30", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5", @@ -380,10 +376,6 @@ "version": "0.0.0", }, }, - "patchedDependencies": { - "three@0.185.1": "patches/three@0.185.1.patch", - "iwer@2.3.0": "patches/iwer@2.3.0.patch", - }, "overrides": { "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -447,8 +439,6 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.1", "", {}, "sha512-agRJn3+EJDUe8AvxTx/LnHA/GErvLE62pSaSk7+MwFOtOv8eWBu/qCq2qoZjBjVZ3C2aiFJCveuSs17KMkYGOw=="], - "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], @@ -473,10 +463,6 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], - - "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -503,14 +489,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.6.0", "", {}, "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw=="], - - "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], - - "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA=="], - - "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@0.2.2", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~1 || ~6", "react": ">=16.3" } }, "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g=="], - "@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="], "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "ktx-parse": "^1.1.0" } }, "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ=="], @@ -589,10 +567,6 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], - "@iwer/devui": ["@iwer/devui@2.3.0", "", { "dependencies": { "@pmndrs/handle": "^6.6.29", "@pmndrs/pointer-events": "^6.6.29", "lucide-react": "^1.20.0", "react": "^19.2.6", "react-dom": "^19.2.6", "styled-components": "^6.4.1", "three": "^0.184.0", "zustand": "^5.0.13" }, "peerDependencies": { "iwer": "^2.3.0" } }, "sha512-UfBFR3qOYt/DUsLik20uF7Jn0NrNN2L0+uiDGJzuKXpXa/2btNOjOj1iSaTthz95MK5/O2HqqC+ymjkRMHi5UQ=="], - - "@iwer/sem": ["@iwer/sem@0.2.5", "", { "dependencies": { "three": "^0.165.0", "ts-proto": "^2.6.0" }, "peerDependencies": { "iwer": "^2.0.0" } }, "sha512-vMCfpu/7Qqc+hkBiGD9pxjeObgrhXOrL0KX94CA3yzJaU0dq0y49HXZT6fC+6X/jOmjaM3hjyE1m2h7ZmLzzyA=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -821,12 +795,6 @@ "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], - "@pmndrs/handle": ["@pmndrs/handle@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "zustand": "^4.5.2" } }, "sha512-KPutdaLpCNPZoqXX2HvlRc+cQMETXdHTF0nKqFHMrJrne34apqELRVig53lO469rJhheedsRVepfdyo4g9Rukw=="], - - "@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.30", "", {}, "sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g=="], - - "@pmndrs/xr": ["@pmndrs/xr@6.6.30", "", { "dependencies": { "@iwer/devui": "^1.1.1", "@iwer/sem": "~0.2.5", "@pmndrs/pointer-events": "~6.6.30", "iwer": "^2.1.0", "meshline": "^3.3.1", "zustand": "^4.5.2" }, "peerDependencies": { "three": "*" } }, "sha512-qy0UQHaXdZs192awbYkde+O/eKn15fz88X08+7EhlVIdsMfPAORR2JDocTzfYmtz8bKyE4z1JMUUvXb9DyUrzg=="], - "@preact/signals": ["@preact/signals@2.9.1", "", { "dependencies": { "@preact/signals-core": "^1.14.0" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-xVqN8mJjbSN5IB/8Ubmd9NN+Ew6zJswoRxrjZbH3YsgkMshFeO6d8zxEFpHRTq9GJZx7cnPs2CnCpFqtGXGNsw=="], "@preact/signals-core": ["@preact/signals-core@1.14.2", "", {}, "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A=="], @@ -915,8 +883,6 @@ "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], - "@react-three/xr": ["@react-three/xr@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "@pmndrs/xr": "~6.6.30", "suspend-react": "^0.1.3", "tunnel-rat": "^0.1.2", "zustand": "^4.5.2" }, "peerDependencies": { "@react-three/fiber": ">=8", "react": ">=18", "react-dom": ">=18", "three": "*" } }, "sha512-C+PYxnDsWvF2WG669DXwlcKnxcy7O+FMhxlaOq75HrRSJRtk4T5iZCv6qak2+8ztcYrip8t79W9E+LSV3P93hA=="], - "@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"], "@repo/typescript-config": ["@repo/typescript-config@workspace:packages/typescript-config"], @@ -1191,14 +1157,10 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], - "case-anything": ["case-anything@2.1.13", "", {}, "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1247,10 +1209,6 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], - - "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="], @@ -1297,8 +1255,6 @@ "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], - "dprint-node": ["dprint-node@1.0.8", "", { "dependencies": { "detect-libc": "^1.0.3" } }, "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg=="], - "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1457,8 +1413,6 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], - "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1597,8 +1551,6 @@ "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], - "iwer": ["iwer@2.3.0", "", { "dependencies": { "gl-matrix": "^3.4.4", "webxr-layers-polyfill": "^1.1.0" } }, "sha512-+aY/BXVIjztCtS4F1hAO2rQy0P1/0JbJ6Jq5QHVscUBBlv8xJlTkkyeg+uSiD9RQ7i5B6k1Blpcx84FQaI7Myw=="], - "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -1673,7 +1625,7 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "lucide-react": ["lucide-react@1.41.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q=="], + "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], @@ -1827,8 +1779,6 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], @@ -1975,12 +1925,8 @@ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], - "styled-components": ["styled-components@6.5.3", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "stylis": "4.3.6" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0", "react-native": ">= 0.68.0" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg=="], - "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -2023,12 +1969,6 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "ts-poet": ["ts-poet@6.12.0", "", { "dependencies": { "dprint-node": "^1.0.8" } }, "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA=="], - - "ts-proto": ["ts-proto@2.12.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.14.1", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" }, "bin": { "protoc-gen-ts_proto": "protoc-gen-ts_proto" } }, "sha512-osbffME+UulBWYF+dNhOzQqCTxb43BsAIfGwPaCyYp47HJS/F83mR4OUGuknc5VGR6jYBcXxPdIUe98/H7KhRg=="], - - "ts-proto-descriptors": ["ts-proto-descriptors@2.1.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], @@ -2109,8 +2049,6 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "webxr-layers-polyfill": ["webxr-layers-polyfill@1.1.0", "", { "dependencies": { "gl-matrix": "^3.4.3" } }, "sha512-GqWE6IFlut8a1Lnh9t1RPnOXud1rZ7wLPvWp7mqTDOYtgorXqlNMhEnI9EqjU33grBx0v3jm0Oc13opkAdmgMQ=="], - "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -2161,20 +2099,12 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@pmndrs/handle/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - - "@pmndrs/xr/@iwer/devui": ["@iwer/devui@1.1.2", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", "@pmndrs/handle": "^6.6.17", "@pmndrs/pointer-events": "^6.6.17", "react": ">=18.3.1", "react-dom": ">=18.3.1", "styled-components": "^6.1.13", "three": "^0.165.0" }, "peerDependencies": { "iwer": "^2.0.1" } }, "sha512-ggF1lXSX14BTYP0QzB4xaurySr2PC+3+rtK/dpCR++giWquzFv2mBw3LW/PaCtdl5mqkZMrQ2GSwfUNg9ZoO+w=="], - - "@pmndrs/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - "@react-grab/cli/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], - "@react-three/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -2209,8 +2139,6 @@ "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "dprint-node/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], - "editor/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], diff --git a/package.json b/package.json index f1ba18f657..56f12089ba 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,6 @@ "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0" }, - "patchedDependencies": { - "three@0.185.1": "patches/three@0.185.1.patch", - }, "workspaces": [ "apps/*", "packages/*", diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx index 26098ae552..093a2f9fd1 100644 --- a/packages/editor/src/components/editor/editor-layout-mobile.tsx +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -40,7 +40,6 @@ export interface EditorLayoutMobileProps { viewerToolbarRight?: ReactNode viewerContent: ReactNode overlays?: ReactNode - immersivePresentation?: boolean } export function EditorLayoutMobile({ @@ -52,7 +51,6 @@ export function EditorLayoutMobile({ viewerToolbarRight, viewerContent, overlays, - immersivePresentation = false, }: EditorLayoutMobileProps) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const activePanel = useEditor((s) => s.activeSidebarPanel) @@ -191,12 +189,11 @@ export function EditorLayoutMobile({ // Otherwise, the viewer extends SHEET_OVERLAP_PX behind the sheet's rounded // corners so the curve reveals viewer content underneath. const baseViewerHeight = Math.max(0, middleH - effectiveSheetH) - const viewerHeight = - isCaptureMode || immersivePresentation - ? middleH - : baseViewerHeight === 0 - ? 0 - : Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX) + const viewerHeight = isCaptureMode + ? middleH + : baseViewerHeight === 0 + ? 0 + : Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX) // While the panel sheet is open, collapse the primary sheet to its handle so // it doesn't peek above. Remember the previous height and restore it on close. @@ -215,11 +212,8 @@ export function EditorLayoutMobile({ }, [panelSheetHeight, committedSheetH]) return ( -
- {!immersivePresentation && navbarSlot} +
+ {navbarSlot}
- {(viewerToolbarLeft || viewerToolbarRight) && - !(isCaptureMode || immersivePresentation) && ( -
-
- {viewerToolbarLeft} -
-
- {viewerToolbarRight} -
+ {(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && ( +
+
+ {viewerToolbarLeft}
- )} +
+ {viewerToolbarRight} +
+
+ )}
{viewerContent}
- {overlays && !immersivePresentation && ( + {overlays && (
{/* Bottom sheet: overlays the lower part of the middle area */} - {!(isCaptureMode || immersivePresentation) && sidebarTabs.length > 0 && ( + {!isCaptureMode && sidebarTabs.length > 0 && ( - {!(isCaptureMode || immersivePresentation) && sidebarTabs.length > 0 && ( + {!isCaptureMode && sidebarTabs.length > 0 && ( )}
diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index 8c3fb9f46a..c2d85c052e 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -165,24 +165,20 @@ function RightColumn({ children, overlays, stageOverlay, - immersivePresentation = false, }: { toolbarLeft?: ReactNode toolbarRight?: ReactNode children: ReactNode overlays?: ReactNode stageOverlay?: ReactNode - immersivePresentation?: boolean }) { return (
{/* Viewer toolbar */} @@ -232,8 +228,6 @@ export interface EditorLayoutV2Props { viewerContent: ReactNode overlays?: ReactNode stageOverlay?: ReactNode - /** Show only the viewer while an immersive session is presenting. */ - immersivePresentation?: boolean } export function EditorLayoutV2({ @@ -246,7 +240,6 @@ export function EditorLayoutV2({ viewerContent, overlays, stageOverlay, - immersivePresentation = false, }: EditorLayoutV2Props) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const isMobile = useIsMobile() @@ -254,7 +247,6 @@ export function EditorLayoutV2({ if (isMobile) { return ( +
{/* Top navbar */} - {!immersivePresentation && navbarSlot} + {navbarSlot} {/* Main content: left column + right column */}
- {!(isCaptureMode || immersivePresentation) && sidebarTabs.length > 0 && ( + {!isCaptureMode && sidebarTabs.length > 0 && ( )} {viewerContent} diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index f0b19180f0..3bdb0b6850 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -18,8 +18,6 @@ import { getMovingNode } from '../../store/use-interaction-scope' // about to snap into lights up. const PLACEMENT_REVEAL_RADIUS = 12 -export const EDITOR_GRID_INPUT_NAME = 'pascal-editor-grid-input' - const UP = new Vector3(0, 1, 0) // PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal // → world +Y), equivalent to the old `rotation-x={-π/2}`. @@ -322,7 +320,6 @@ export const Grid = ({ geometry={geometry} layers={GRID_LAYER} material={material} - name={EDITOR_GRID_INPUT_NAME} ref={gridRef} renderOrder={1} /> diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 74025e8b06..0503b4335e 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -13,11 +13,10 @@ import { import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three' +import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' -import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' import useEditor from '../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, @@ -164,14 +163,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: if (event.button !== 0) return event.stopPropagation() suppressBoxSelectForPointer(event) - const spatialPointerId = getSpatialPointerId(event.nativeEvent) - const spatialRay = spatialPointerId ? event.ray.clone() : null - if (spatialPointerId) { - const target = event.object as typeof event.object & { - setPointerCapture?: (pointerId: number) => void - } - target.setPointerCapture?.(event.pointerId) - } frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } const center = rest.pivot.clone() @@ -222,12 +213,10 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: ) } - if (!spatialRay) { - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - } + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) const hit = new Vector3() - if (!(spatialRay ?? raycaster.ray).intersectPlane(plane, hit)) return + if (!raycaster.ray.intersectPlane(plane, hit)) return const initialAngle = angleOf(hit) document.body.style.cursor = 'grabbing' @@ -241,13 +230,15 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: }) setIsDragging(true) - const applyRay = (ray: Ray, freeRotation: boolean) => { + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) const moveHit = new Vector3() - if (!ray.intersectPlane(plane, moveHit)) return + if (!raycaster.ray.intersectPlane(plane, moveHit)) return let delta = angleOf(moveHit) - initialAngle while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI - if (!freeRotation) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP + if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP // Shared rigid-rotation math (also used by the keyboard group R/T); // see `rotateGroupPatches` for the orbit/yaw handedness contract. @@ -295,14 +286,8 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: }) } } - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) - applyRay(raycaster.ray, e.shiftKey) - } const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - let releaseSpatialCapture: (() => void) | null = null const clearLivePreviews = () => { const overrides = useLiveNodeOverrides.getState() const liveTransforms = useLiveTransforms.getState() @@ -318,8 +303,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) - releaseSpatialCapture?.() - releaseSpatialCapture = null if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) @@ -383,16 +366,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: for (const id of affectedIds) { useLiveTransforms.getState().clear(id) } - if (spatialPointerId && spatialRay) { - releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, { - onMove: (ray) => { - spatialRay.copy(ray) - applyRay(spatialRay, false) - }, - onRelease: onUp, - onCancel, - }) - } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) @@ -425,7 +398,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: onPointerDown={activate} onPointerEnter={onHoverEnter} onPointerLeave={onHoverLeave} - pointerEventsOrder={10} scale={baseScale} /> diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index aedf1f3e1b..e3361633b4 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -1,9 +1,8 @@ 'use client' import { type Cursor, emitter } from '@pascal-app/core' -import { markPureRaycast } from '@pascal-app/viewer' -import { type ThreeEvent, useThree } from '@react-three/fiber' -import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import type { ThreeEvent } from '@react-three/fiber' +import { type ReactNode, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, type BufferGeometry, @@ -15,18 +14,14 @@ import { type Group, type Intersection, Mesh, - type Object3D, - type Ray, type Raycaster, Shape, TorusGeometry, - Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation' -import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input' import useEditor from '../../../store/use-editor' // While a press-drag move is in flight (`placementDragMode`), the move tool @@ -35,13 +30,10 @@ import useEditor from '../../../store/use-editor' // (`wall:move` for openings, `grid:move` for free movers), freezing the drag. // Make every handle hit area inert for the duration; the indicator mesh still // renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible. -export const hitAreaRaycast = markPureRaycast(function hitAreaRaycast( - this: Mesh, - raycaster: Raycaster, - intersects: Intersection[], -): void { +export function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void { + if (useEditor.getState().placementDragMode) return Mesh.prototype.raycast.call(this, raycaster, intersects) -}) +} export const ARROW_SCALE = 0.65 export const ARROW_COLOR = '#8381ed' @@ -50,7 +42,6 @@ export const NO_RAYCAST = () => null export const HIT_AREA_MARGIN = 0.035 const HIT_AREA_RENDER_ORDER = 1011 -const HIT_AREA_POINTER_EVENTS_ORDER = 10 const HIT_AREA_THICKNESS = 0.08 const CHEVRON_MIN_X = -0.2 const CHEVRON_MAX_X = 0.22 @@ -288,6 +279,44 @@ export function createMoveCrossHandleGeometry() { return merged } +export function createArrowHitAreaGeometry() { + const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2 + const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2 + const geometry = new CylinderGeometry( + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + length, + 16, + ) + geometry.rotateZ(-Math.PI / 2) + geometry.translate(centerX, 0, 0) + geometry.computeBoundingSphere() + return geometry +} + +// The move cross is a plus, not a disk. A disk-shaped hit area fills the four +// corner gaps between the arms, so a neighbouring node sitting next to the +// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the +// invisible grip and can't be picked. Wrap the visible arms instead: two flat +// arm boxes (length/width + margin) merged into a plus, leaving the corners +// empty so co-located neighbours stay selectable while the grip stays grabbable. +function createMoveCrossHitAreaGeometry() { + const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2 + const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2 + const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth) + const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength) + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeBoundingSphere() + return merged +} + function createPlusHandleGeometry() { const shape = new Shape() shape.moveTo(-PLUS_HALF_WIDTH, PLUS_HALF_LENGTH) @@ -336,44 +365,6 @@ function createPlusHitAreaGeometry() { return merged } -export function createArrowHitAreaGeometry() { - const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2 - const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2 - const geometry = new CylinderGeometry( - CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, - CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, - length, - 16, - ) - geometry.rotateZ(-Math.PI / 2) - geometry.translate(centerX, 0, 0) - geometry.computeBoundingSphere() - return geometry -} - -// The move cross is a plus, not a disk. A disk-shaped hit area fills the four -// corner gaps between the arms, so a neighbouring node sitting next to the -// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the -// invisible grip and can't be picked. Wrap the visible arms instead: two flat -// arm boxes (length/width + margin) merged into a plus, leaving the corners -// empty so co-located neighbours stay selectable while the grip stays grabbable. -function createMoveCrossHitAreaGeometry() { - const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2 - const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2 - const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth) - const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength) - const merged = mergeGeometries([armX, armZ], false) - if (!merged) { - armZ.dispose() - armX.computeBoundingSphere() - return armX - } - armX.dispose() - armZ.dispose() - merged.computeBoundingSphere() - return merged -} - export function createRotateArrowHitAreaGeometry() { const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS const geometry = new TorusGeometry( @@ -494,76 +485,16 @@ export function InvisibleHandleHitArea({ onPointerLeave: PointerHandler scale: number }) { - const camera = useThree((state) => state.camera) - const canvas = useThree((state) => state.gl.domElement) - - const windowPointerEventForRay = ( - type: 'pointermove' | 'pointerup' | 'pointercancel', - ray: Ray, - ) => { - const point = ray.at(4, new Vector3()).project(camera) - const rect = canvas.getBoundingClientRect() - return new PointerEvent(type, { - bubbles: true, - button: 0, - buttons: type === 'pointermove' ? 1 : 0, - clientX: rect.left + ((point.x + 1) / 2) * rect.width, - clientY: rect.top + ((1 - point.y) / 2) * rect.height, - pointerType: 'xr', - }) - } - - const handlePointerDown: PointerHandler = (event) => { - const spatialPointerId = getSpatialPointerId(event.nativeEvent) - if (spatialPointerId) { - const target = event.object as Object3D & { - setPointerCapture?: (pointerId: number) => void - } - target.setPointerCapture?.(event.pointerId) - const initialPointer = windowPointerEventForRay('pointermove', event.ray) - const nativeEvent = event.nativeEvent as PointerEvent - try { - Object.defineProperties(nativeEvent, { - clientX: { configurable: true, value: initialPointer.clientX }, - clientY: { configurable: true, value: initialPointer.clientY }, - pointerId: { configurable: true, value: event.pointerId }, - pointerType: { configurable: true, value: 'xr' }, - }) - } catch { - // Direct-ray handle sessions do not need projected DOM coordinates. - } - spatialPointerInput.capture(spatialPointerId, { - onMove: (ray) => window.dispatchEvent(windowPointerEventForRay('pointermove', ray)), - onRelease: () => window.dispatchEvent(windowPointerEventForRay('pointerup', event.ray)), - onCancel: () => window.dispatchEvent(windowPointerEventForRay('pointercancel', event.ray)), - }) - } - onPointerDown(event) - } - const ref = useRef(null) - useLayoutEffect(() => { - const mesh = ref.current - if (!mesh) return - // Subscribe synchronously so the next pointer event sees drag state before React renders. - const syncRaycast = () => { - mesh.raycast = useEditor.getState().placementDragMode ? NO_RAYCAST : hitAreaRaycast - } - syncRaycast() - return useEditor.subscribe(syncRaycast) - }, []) - return ( restore() -} - -function getSpatialPointerSource(event: ThreeEvent): object | null { - const pointerId = getSpatialPointerId(event.nativeEvent) - return typeof pointerId === 'object' ? pointerId : null } export function useHandleDrag(args: UseHandleDragArgs) { @@ -127,17 +120,9 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (event.button !== 0) return event.stopPropagation() suppressBoxSelectForPointer(event) - const spatialPointerSource = getSpatialPointerSource(event) if (args.kind === 'tap') { - const restoreInputDragging = suppressInputDraggingUntilPointerRelease(event.pointerId) - if (spatialPointerSource) { - spatialPointerInput.capture(spatialPointerSource, { - onMove: () => undefined, - onRelease: restoreInputDragging, - onCancel: restoreInputDragging, - }) - } + suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId) swallowNextClick() sfxEmitter.emit('sfx:item-pick') document.body.style.cursor = '' @@ -147,7 +132,6 @@ export function useHandleDrag(args: UseHandleDragArgs) { const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args rideObject.updateMatrixWorld() - const spatialRay = spatialPointerSource ? event.ray.clone() : null const ndc = new Vector2() const setPointerRay = (clientX: number, clientY: number) => { @@ -159,12 +143,10 @@ export function useHandleDrag(args: UseHandleDragArgs) { raycaster.setFromCamera(ndc, camera) } const getPointerRay: GetPointerRay = (clientX, clientY, target) => { - if (spatialRay) return target.copy(spatialRay) setPointerRay(clientX, clientY) return target.copy(raycaster.ray) } const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { - if (spatialRay) return spatialRay.intersectPlane(plane, target) setPointerRay(clientX, clientY) return raycaster.ray.intersectPlane(plane, target) } @@ -198,7 +180,6 @@ export function useHandleDrag(args: UseHandleDragArgs) { let lastPatch: Partial | null = null let historyPaused = true let altKey = event.nativeEvent.altKey - let releaseSpatialCapture: (() => void) | null = null const resumeHistory = () => { if (!historyPaused) return @@ -227,8 +208,6 @@ export function useHandleDrag(args: UseHandleDragArgs) { window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('keyup', onKeyUp, true) - releaseSpatialCapture?.() - releaseSpatialCapture = null if (document.body.style.cursor === cursor) { document.body.style.cursor = '' } @@ -285,23 +264,6 @@ export function useHandleDrag(args: UseHandleDragArgs) { } dragCleanupRef.current = onCancel - if (spatialPointerSource && spatialRay) { - releaseSpatialCapture = spatialPointerInput.capture(spatialPointerSource, { - onMove: (ray) => { - spatialRay.copy(ray) - onMove( - new PointerEvent('pointermove', { - button: 0, - buttons: 1, - pointerId: event.pointerId, - pointerType: 'xr', - }), - ) - }, - onRelease: onUp, - onCancel, - }) - } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 0003032f9e..aafb678a0d 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -19,7 +19,6 @@ import { SceneEnvironment, useViewer, Viewer, - type ViewerXRConfig, } from '@pascal-app/viewer' import { memo, @@ -218,14 +217,6 @@ export interface EditorProps { * module-load URL flags or shading toggles. */ disablePostFx?: boolean - /** Use the viewer's WebGL backend for host features such as immersive WebXR. */ - forceWebGL?: boolean - - /** Host-provided immersive XR runtime for the main 3D canvas. */ - xr?: ViewerXRConfig - - /** Hide authoring chrome and let the viewer fill the host while XR presents. */ - immersivePresentation?: boolean // Version preview overlays (rendered by host app) sidebarOverlay?: ReactNode @@ -776,18 +767,14 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isVersionPreviewMode, isLoading, isFirstPersonMode, - isXRMode, isStudioMode, - renderPaused, onThumbnailCapture, viewerSceneSlot, }: { isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean - isXRMode: boolean isStudioMode: boolean - renderPaused: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void viewerSceneSlot?: ReactNode }) { @@ -797,12 +784,11 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ // selection, editing handles, and the tool manager (which mounts the site // boundary flags) so the framed shot stays clean. const isCaptureMode = useEditor((s) => s.isCaptureMode) - const noEditing = - isVersionPreviewMode || isFirstPersonMode || isXRMode || isStudioMode || isCaptureMode + const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode return ( <> - {!noEditing && } + {!(isFirstPersonMode || isStudioMode || isCaptureMode) && } {!noEditing && } {!noEditing && } {!noEditing && } @@ -814,21 +800,21 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } - {!(isFirstPersonMode || isXRMode) && } + {!isFirstPersonMode && } {isFirstPersonMode ? : } - {!noEditing && } + {!noEditing && } - {!noEditing && } - {!noEditing && } - {!(isLoading || isFirstPersonMode || isXRMode) && } + + + {!(isLoading || isFirstPersonMode) && } {!(isLoading || noEditing) && } {isFirstPersonMode && } - {isCaptureMode && !isXRMode && } - {!isXRMode && } - {!isXRMode && } - {!(isFirstPersonMode || isXRMode) && } + {isCaptureMode && } + + + {!isFirstPersonMode && } {!noEditing && viewerSceneSlot} @@ -1016,9 +1002,6 @@ const ViewerCanvas = memo(function ViewerCanvas({ viewerSceneSlot, floorplanSceneSlot, disablePostFx = false, - forceWebGL = false, - xr, - immersivePresentation = false, }: { isVersionPreviewMode: boolean isLoading: boolean @@ -1032,9 +1015,6 @@ const ViewerCanvas = memo(function ViewerCanvas({ viewerSceneSlot?: ReactNode floorplanSceneSlot?: ReactNode disablePostFx?: boolean - forceWebGL?: boolean - xr?: ViewerXRConfig - immersivePresentation?: boolean }) { const viewMode = useEditor((s) => s.viewMode) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) @@ -1113,10 +1093,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ }} >
- +
{viewMode === 'split' && (
- {!(showLoader || isVersionPreviewMode || immersivePresentation) && } + {!(showLoader || isVersionPreviewMode) && } ) }) @@ -1261,9 +1234,6 @@ function EditorContent({ onLoaderChange, onThumbnailCapture, disablePostFx = false, - forceWebGL = false, - xr, - immersivePresentation = false, sidebarOverlay, viewerBanner, settingsPanelProps, @@ -1327,7 +1297,6 @@ function EditorContent({ // Load scene on mount (or when onLoad identity changes, e.g. project switch) useEffect(() => { - void sceneLoadAttempt let cancelled = false async function load() { @@ -1501,7 +1470,6 @@ function EditorContent({ const viewerCanvas = ( ) @@ -1590,7 +1556,6 @@ function EditorContent({ ) : ( <> diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 0676f3ef42..80568c44ee 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -49,7 +49,6 @@ import { import { isHistoryShortcut } from '../../lib/history' import { endpointReshapeScope } from '../../lib/interaction/scope' import { sfxEmitter } from '../../lib/sfx-bus' -import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, @@ -770,9 +769,7 @@ function WallBaseElevationHandle({ const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( levelObject.matrixWorld, ) - const planeNormal = new Vector3() - .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld) - .setY(0) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) if (planeNormal.lengthSq() === 0) return null planeNormal.normalize() const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) @@ -915,17 +912,12 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { // the camera (projected to horizontal). Raycasting against it converts // pointer movement into a world-space Y value. const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) - const planeNormal = new Vector3() - .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld) - .setY(0) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) if (planeNormal.lengthSq() === 0) return planeNormal.normalize() const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) const ndc = new Vector2() - const spatialPointerId = getSpatialPointerId(event.nativeEvent) - const spatialPointerSource = typeof spatialPointerId === 'object' ? spatialPointerId : null - const spatialRay = spatialPointerSource ? event.ray.clone() : null const setNDC = (clientX: number, clientY: number) => { const rect = gl.domElement.getBoundingClientRect() ndc.set( @@ -934,12 +926,10 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { ) } - if (!spatialRay) { - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - } + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) const hit = new Vector3() - if (!(spatialRay ?? raycaster.ray).intersectPlane(plane, hit)) return + if (!raycaster.ray.intersectPlane(plane, hit)) return // Dragging the top makes the wall custom-height; seed from the resolved // effective height so a plane-bound wall's drag starts at its real top. @@ -947,7 +937,6 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight - let releaseSpatialCapture: (() => void) | null = null document.body.style.cursor = 'ns-resize' sfxEmitter.emit('sfx:item-pick') @@ -965,11 +954,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const onMove = (e: PointerEvent) => { setNDC(e.clientX, e.clientY) raycaster.setFromCamera(ndc, camera) - applyRay(raycaster.ray) - } - const applyRay = (ray: Ray) => { const intersection = new Vector3() - if (!ray.intersectPlane(plane, intersection)) return + if (!raycaster.ray.intersectPlane(plane, intersection)) return const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) pendingHeight = newHeight useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) @@ -981,8 +967,6 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) - releaseSpatialCapture?.() - releaseSpatialCapture = null if (document.body.style.cursor === 'ns-resize') { document.body.style.cursor = '' } @@ -1023,16 +1007,6 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { } dragCleanupRef.current = cleanup - if (spatialPointerSource && spatialRay) { - releaseSpatialCapture = spatialPointerInput.capture(spatialPointerSource, { - onMove: (ray) => { - spatialRay.copy(ray) - applyRay(spatialRay) - }, - onRelease: onUp, - onCancel, - }) - } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 43f3f07dc8..b1bc2132ed 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -15,7 +15,6 @@ import { Icon } from '@iconify/react' import { Move, Trash2 } from 'lucide-react' import { type ComponentType, lazy, Suspense, useCallback } from 'react' import { resolveMoveActionNode } from '../../../lib/direct-manipulation' -import { commitParametricNodeFields } from '../../../lib/parametric-node-update' import { sfxEmitter } from '../../../lib/sfx-bus' import { collectZoneContentIds } from '../../../lib/zone-content' import useEditor from '../../../store/use-editor' @@ -62,9 +61,22 @@ export function ParametricInspector({ const handleUpdate = useCallback( (patch: Partial) => { if (!selectedId) return - commitParametricNodeFields(selectedId, patch) + const scene = useScene.getState() + const node = scene.nodes[selectedId] + if (parametrics?.derive && node) { + const next = { ...node, ...patch } as AnyNode + patch = { ...patch, ...parametrics.derive(next, patch, node as AnyNode) } + } + // Bundle the edited node + any reconcile follow-ups into ONE + // updateNodes call so a single inspector edit is a single undo step. + const updates: { id: AnyNodeId; data: Partial }[] = [{ id: selectedId, data: patch }] + if (parametrics?.reconcile && node) { + const next = { ...node, ...patch } as AnyNode + updates.push(...parametrics.reconcile(node as AnyNode, next)) + } + scene.updateNodes(updates) }, - [selectedId], + [selectedId, parametrics], ) const clearSelection = useCallback(() => { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 441a763b68..094a28cd39 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -133,19 +133,6 @@ const exitToSelectAfterUnconsumedCancel = () => { useEditor.getState().setSelectedReferenceId(null) } -// Cancel the active editor action with the same consume-or-exit semantics as -// Escape. Spatial inputs use this instead of unconditionally selecting the -// Select tool, so multi-step tools can keep their tool active after clearing -// the current draft. -export const cancelActiveTool = () => { - _toolCancelConsumed = false - emitter.emit('tool:cancel') - if (!_toolCancelConsumed) { - exitToSelectAfterUnconsumedCancel() - } - return _toolCancelConsumed -} - // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…) // reads as "abort this action", not history undo — behave exactly like Escape // and report whether anything was in flight so the undo/redo arms know to @@ -393,9 +380,14 @@ export const useKeyboard = ({ return } + _toolCancelConsumed = false + emitter.emit('tool:cancel') + // Only switch to select mode if no tool had an active mid-action to cancel. // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool) - cancelActiveTool() + if (!_toolCancelConsumed) { + exitToSelectAfterUnconsumedCancel() + } } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('site') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d0eacedcb6..7f94d73da6 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -49,7 +49,7 @@ export { FloatingActionMenu as FloatingMenu } from './components/editor/floating // camera controls via the `useViewer.inputDragging` / `useEditor.movingNode` // flags. Tools place onto `useViewer.selection.levelId`, so the host must set a // building + level selection first. -export { EDITOR_GRID_INPUT_NAME, Grid } from './components/editor/grid' +export { Grid } from './components/editor/grid' export { DimensionPill, type DimensionPillPart, @@ -79,12 +79,10 @@ export { useInvisibleHitAreaMaterial, } from './components/editor/node-arrow-handles' export { QuickMeasurementCard } from './components/editor/quick-measurement-card' -export { SelectionManager } from './components/editor/selection-manager' export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' -export { WallMoveSideHandles } from './components/editor/wall-move-side-handles' export { useFloorplanRender } from './components/editor-2d/floorplan-render-context' export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer' export { FloorplanGeometryRenderer } from './components/editor-2d/renderers/floorplan-geometry-renderer' @@ -330,7 +328,7 @@ export type { SaveStatus } from './hooks/use-auto-save' // can express their affordances declaratively in their own folder. export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). -export { cancelActiveTool, markToolCancelConsumed } from './hooks/use-keyboard' +export { markToolCancelConsumed } from './hooks/use-keyboard' export { useReducedMotion } from './hooks/use-reduced-motion' export { type Selection, useSelection } from './hooks/use-selection' export { @@ -361,7 +359,6 @@ export { continuationContextOf, nextContinuation, } from './lib/continuation' -export { canDirectMoveNode } from './lib/direct-manipulation' export { createEditorApi } from './lib/editor-api' export { clearStructuralElevationGuide, @@ -518,13 +515,6 @@ export { metersToLinearUnit, squareMetersToAreaUnit, } from './lib/measurements' -export { - cyclePaintScope, - type PaintHoverInfo, - type PaintScope, - paintScopeLabel, -} from './lib/paint-scope' -export { commitParametricNodeFields } from './lib/parametric-node-update' export { consumePlacementDragRelease } from './lib/placement-drag-release' export { addFreshPlacementMetadata, @@ -556,7 +546,7 @@ export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from '. export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { movementSfxStepKey } from './lib/sfx/movement-tick' -export { emitDeleteSFX, triggerSFX } from './lib/sfx-bus' +export { triggerSFX } from './lib/sfx-bus' export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player' export { clearSlabSnapFeedback, @@ -569,14 +559,12 @@ export { type SlabPlanSnapResult, } from './lib/slab-plan-snap' export { - cycleSnappingModeIn, getSnappingModeLabel, resolveSnapFlags, type SnapContext, type SnapFlags, type SnappingMode, } from './lib/snapping-mode' -export { getSpatialPointerId, spatialPointerInput } from './lib/spatial-pointer-input' export { duplicateStairSubtree } from './lib/stair-duplication' export { getBuildingLevelsForLevel, @@ -594,15 +582,11 @@ export { type SurfacePlanSnapResult, } from './lib/surface-plan-snap' export { - brushRadiusRange, - clipTerrainPatchToSite, - commitStroke, fieldExtentForSite, flattenSite, resetSiteTerrain, resolveFlattenTarget, sculptFieldForSite, - terrainPointInsideSite, } from './lib/terrain-sculpt' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // nodes` so they don't need their own copy / their own tailwind-merge @@ -651,7 +635,6 @@ export { isAngleSnapActive, isGridSnapActive, isMagneticSnapActive, - selectDefaultBuildingAndLevel, } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' diff --git a/packages/editor/src/lib/parametric-node-update.ts b/packages/editor/src/lib/parametric-node-update.ts deleted file mode 100644 index 814ea15be6..0000000000 --- a/packages/editor/src/lib/parametric-node-update.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - type AnyNode, - type AnyNodeId, - nodeRegistry, - type ParametricDescriptor, - useScene, -} from '@pascal-app/core' - -export function commitParametricNodeFields( - nodeId: AnyNodeId, - requestedPatch: Partial, -): void { - const scene = useScene.getState() - const node = scene.nodes[nodeId] - if (!node) return - - const parametrics = nodeRegistry.get(node.type)?.parametrics as - | ParametricDescriptor - | undefined - let patch = requestedPatch as Record - if (parametrics?.derive) { - const next = { ...node, ...patch } as AnyNode - patch = { - ...patch, - ...parametrics.derive(next, patch as Partial, node), - } - } - - const updates: { id: AnyNodeId; data: Partial }[] = [ - { id: nodeId, data: patch as Partial }, - ] - if (parametrics?.reconcile) { - const next = { ...node, ...patch } as AnyNode - updates.push(...parametrics.reconcile(node, next)) - } - scene.updateNodes(updates) -} diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts deleted file mode 100644 index 9f06d85738..0000000000 --- a/packages/editor/src/lib/scene.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test' -import { nodeRegistry, registerNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { z } from 'zod' -import useEditor from '../store/use-editor' -import { normalizeSceneGraphNodes, syncEditorSelectionFromCurrentScene } from './scene' - -const building = { - children: ['level_scene-root'], - id: 'building_scene-root', - object: 'node', - parentId: null, - position: [0, 0, 0], - rotation: [0, 0, 0], - type: 'building', - visible: true, -} - -const level = { - children: ['wall_scene-root'], - id: 'level_scene-root', - level: 0, - object: 'node', - parentId: building.id, - type: 'level', - visible: true, -} - -const wall = { - children: [], - end: [4, 0], - id: 'wall_scene-root', - object: 'node', - parentId: level.id, - start: [0, 0], - type: 'wall', - visible: true, -} - -describe('scene selection synchronization', () => { - beforeEach(() => { - useViewer.getState().resetSelection() - useEditor.setState({ mode: 'select', phase: 'site', tool: null }) - }) - - test('enters the first level when a scene graph is rooted at a building', () => { - useScene.setState({ - nodes: { - [building.id]: building, - [level.id]: level, - [wall.id]: wall, - }, - rootNodeIds: [building.id], - } as never) - - syncEditorSelectionFromCurrentScene() - - expect(useViewer.getState().selection).toMatchObject({ - buildingId: building.id, - levelId: level.id, - }) - expect(useEditor.getState().phase).toBe('structure') - }) -}) - -describe('scene graph normalization', () => { - test('materializes registered schema defaults before the graph reaches renderers', () => { - const restoreRegistry = nodeRegistry._snapshot() - try { - registerNode({ - kind: 'test-scene-normalization', - schema: z.object({ - id: z.string(), - position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), - rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), - type: z.literal('test-scene-normalization'), - }), - schemaVersion: 1, - } as never) - - expect( - normalizeSceneGraphNodes({ - test: { id: 'test', type: 'test-scene-normalization' }, - unknown: { id: 'unknown', type: 'unknown-kind', custom: true }, - }), - ).toEqual({ - test: { - id: 'test', - position: [0, 0, 0], - rotation: [0, 0, 0], - type: 'test-scene-normalization', - }, - unknown: { id: 'unknown', type: 'unknown-kind', custom: true }, - }) - } finally { - restoreRegistry() - } - }) -}) diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 45c3358b76..52bc3795e9 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -276,13 +276,9 @@ function getRestoredSelectionForScene( export function syncEditorSelectionFromCurrentScene() { const sceneNodes = useScene.getState().nodes as Record const sceneRootIds = useScene.getState().rootNodeIds + const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child) - const rootNodes = sceneRootIds.map((id) => sceneNodes[id]).filter(Boolean) - const firstBuilding = - rootNodes.find((node) => node.type === 'building') ?? - rootNodes - .flatMap((node) => (Array.isArray(node.children) ? node.children.map(resolve) : [])) - .find((node) => node?.type === 'building') + const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState()) const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState) @@ -400,25 +396,11 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } -export function normalizeSceneGraphNodes( - nodes: Readonly>, -): Record { - return Object.fromEntries( - Object.entries(nodes).map(([id, value]) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return [id, value] - const type = (value as { type?: unknown }).type - if (typeof type !== 'string') return [id, value] - const parsed = nodeRegistry.get(type)?.schema.safeParse(value) - return [id, parsed?.success ? parsed.data : value] - }), - ) -} - export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() if (hasUsableSceneGraph(sceneGraph)) { const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph - useScene.getState().setScene(normalizeSceneGraphNodes(nodes) as any, rootNodeIds as any, { + useScene.getState().setScene(nodes as any, rootNodeIds as any, { collections: collections as any, materials: materials as any, installedPlugins: installedPlugins ?? defaultInstalledPlugins, diff --git a/packages/editor/src/lib/spatial-pointer-input.test.ts b/packages/editor/src/lib/spatial-pointer-input.test.ts deleted file mode 100644 index 30f9d170bb..0000000000 --- a/packages/editor/src/lib/spatial-pointer-input.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { Ray, Vector3 } from 'three' -import { getSpatialPointerId, SpatialPointerInput } from './spatial-pointer-input' - -describe('SpatialPointerInput', () => { - test('recognizes current and legacy XR native event shapes', () => { - const source = {} - expect(getSpatialPointerId({ inputSource: source })).toBe(source) - expect(getSpatialPointerId({ pointerState: { inputSource: source } })).toBe(source) - expect(getSpatialPointerId({ pointerType: 'mouse' })).toBeNull() - }) - - test('keeps move and release bound to the pointer that captured a handle', () => { - const input = new SpatialPointerInput() - const moves: Ray[] = [] - let releases = 0 - - input.capture(42, { - onMove: (ray) => moves.push(ray.clone()), - onRelease: () => releases++, - onCancel: () => undefined, - }) - - const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0)) - expect(input.move(7, ray)).toBe(false) - expect(input.move(42, ray)).toBe(true) - expect(input.release(7)).toBe(false) - expect(input.release(42)).toBe(true) - expect(moves).toHaveLength(1) - expect(moves[0]?.origin.toArray()).toEqual([1, 2, 3]) - expect(releases).toBe(1) - expect(input.move(42, ray)).toBe(false) - }) - - test('cancels a captured handle without releasing it', () => { - const input = new SpatialPointerInput() - let cancels = 0 - let releases = 0 - - input.capture(9, { - onMove: () => undefined, - onRelease: () => releases++, - onCancel: () => cancels++, - }) - - expect(input.cancel(9)).toBe(true) - expect(cancels).toBe(1) - expect(releases).toBe(0) - }) -}) diff --git a/packages/editor/src/lib/spatial-pointer-input.ts b/packages/editor/src/lib/spatial-pointer-input.ts deleted file mode 100644 index 17c8e62858..0000000000 --- a/packages/editor/src/lib/spatial-pointer-input.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Ray } from 'three' - -type SpatialPointerCapture = { - onMove: (ray: Ray) => void - onRelease: () => void - onCancel: () => void -} - -export type SpatialPointerId = object | number | string - -export function getSpatialPointerId(nativeEvent: unknown): SpatialPointerId | null { - if (!nativeEvent || typeof nativeEvent !== 'object') return null - const event = nativeEvent as { - inputSource?: object - pointerState?: { inputSource?: object } - } - return event.inputSource ?? event.pointerState?.inputSource ?? null -} - -export class SpatialPointerInput { - private readonly captures = new Map() - - capture(pointerId: SpatialPointerId, capture: SpatialPointerCapture): () => void { - this.captures.set(pointerId, capture) - return () => { - if (this.captures.get(pointerId) === capture) { - this.captures.delete(pointerId) - } - } - } - - move(pointerId: SpatialPointerId, ray: Ray): boolean { - const capture = this.captures.get(pointerId) - if (!capture) return false - capture.onMove(ray) - return true - } - - release(pointerId: SpatialPointerId): boolean { - const capture = this.captures.get(pointerId) - if (!capture) return false - this.captures.delete(pointerId) - capture.onRelease() - return true - } - - cancel(pointerId: SpatialPointerId): boolean { - const capture = this.captures.get(pointerId) - if (!capture) return false - this.captures.delete(pointerId) - capture.onCancel() - return true - } -} - -export const spatialPointerInput = new SpatialPointerInput() diff --git a/packages/viewer/src/components/viewer/frame-limiter.tsx b/packages/viewer/src/components/viewer/frame-limiter.tsx index f143ac1fed..ccd1859939 100644 --- a/packages/viewer/src/components/viewer/frame-limiter.tsx +++ b/packages/viewer/src/components/viewer/frame-limiter.tsx @@ -83,11 +83,6 @@ const FrameLimiter: React.FC = ({ fps = 50, paused = false }) } function tick(t: DOMHighResTimeStamp) { raf = requestAnimationFrame(tick) - // While an immersive XR session is presenting, the XR session's - // requestAnimationFrame loop owns rendering. A window RAF here can - // render with no XRFrame and overwrite the XR framebuffer between - // headset frames. - if (renderer.xr?.isPresenting) return syncSize() const frameTime = clock.sample(t, interval) if (frameTime === null) return @@ -95,7 +90,6 @@ const FrameLimiter: React.FC = ({ fps = 50, paused = false }) timeSpan('frame-cpu', () => advance(frameTime)) } function kick() { - if (renderer.xr?.isPresenting) return syncSize() const frameTime = clock.step(1 / 1000) nextFrameTimeRef.current = frameTime diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 55e6eddf0d..6dd57a2723 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -9,7 +9,6 @@ import { } from '@pascal-app/core' import { Canvas, extend, type ThreeElement, useFrame, useThree } from '@react-three/fiber' import { - type ComponentType, forwardRef, useEffect, useImperativeHandle, @@ -31,13 +30,6 @@ import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' -import { shouldMountPostProcessingRenderDriver } from '../../xr/frame-loop' -import { GOD_ORIGIN_POSITION } from '../../xr/god-mode' -import { PlayerModeScene } from '../../xr/mode-switching' -import { immersiveXRBackgroundColor } from '../../xr/presentation-background' -import { ImmersiveXRPresentationProvider } from '../../xr/presentation-context' -import { ViewerXRSessionRoot } from '../../xr/session-root' -import type { ViewerXRStore } from '../../xr/store' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' import { BATCH_SPIKE_ENABLED, BatchedMeshSpike } from './batched-mesh-spike' @@ -176,7 +168,7 @@ type WebGPUDeviceLike = { removeEventListener?: (type: string, listener: EventListener) => void } -function GPUDeviceWatcher({ intentionalWebGL = false }: { intentionalWebGL?: boolean }) { +function GPUDeviceWatcher() { const gl = useThree((s) => s.gl) useEffect(() => { @@ -189,12 +181,10 @@ function GPUDeviceWatcher({ intentionalWebGL = false }: { intentionalWebGL?: boo const device = backend?.device as WebGPUDeviceLike | undefined if (!device) { - if (!intentionalWebGL) { - console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { - backend: backend?.constructor?.name ?? 'unknown', - rendererType: (gl as any).constructor?.name ?? 'unknown', - }) - } + console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { + backend: backend?.constructor?.name ?? 'unknown', + rendererType: (gl as any).constructor?.name ?? 'unknown', + }) return } @@ -220,7 +210,7 @@ function GPUDeviceWatcher({ intentionalWebGL = false }: { intentionalWebGL?: boo return () => { device.removeEventListener?.('uncapturederror', onUncapturedError) } - }, [gl, intentionalWebGL]) + }, [gl]) return null } @@ -238,11 +228,6 @@ function ToneMappingExposure() { return null } -function ImmersiveXRBackground() { - const background = useViewer((state) => immersiveXRBackgroundColor(state.sceneTheme)) - return -} - function hasPendingSceneBuildWork() { const { dirtyNodes, nodes, rootNodeIds } = useScene.getState() @@ -337,15 +322,6 @@ function SceneReadyTracker({ return null } -export interface ViewerXRConfig { - store: ViewerXRStore - playerModes?: boolean - multiview?: boolean - originPosition?: [number, number, number] - session?: XRSession - inputSourceOverlay?: ComponentType<{ type: 'controller' | 'hand' }> -} - interface ViewerProps { children?: React.ReactNode hoverStyles?: HoverStyles @@ -406,10 +382,6 @@ interface ViewerProps { disablePostFx?: boolean /** Keep the mounted renderer/context warm without advancing scene frames. */ renderPaused?: boolean - /** Mount the viewer in immersive WebXR mode using a WebGL renderer. */ - xr?: ViewerXRConfig - /** Force the WebGL backend for non-XR consumers that require it. */ - forceWebGL?: boolean } /** Imperative handle exposed via `ref` on ``. */ @@ -440,8 +412,6 @@ const Viewer = forwardRef(function Viewer( maxFps = 50, disablePostFx = false, renderPaused = false, - xr, - forceWebGL = false, }, ref, ) { @@ -547,15 +517,6 @@ const Viewer = forwardRef(function Viewer( if (showGpuFallback) onSceneReadyChange?.(true) }, [showGpuFallback, onSceneReadyChange]) - useEffect(() => { - if (!xr?.session) return - - // An already-active immersive session can suppress the initial observer - // notification when the WebGL canvas replaces the desktop WebGPU canvas. - const timeout = window.setTimeout(() => window.dispatchEvent(new Event('resize')), 0) - return () => window.clearTimeout(timeout) - }, [xr?.session]) - if (showGpuFallback) { return } @@ -575,12 +536,10 @@ const Viewer = forwardRef(function Viewer( gl={ ((props: { canvas?: HTMLCanvasElement; powerPreference?: RendererPowerPreference }) => { const canvas = props.canvas - const xrMultiview = xr?.multiview ?? false const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined if (cached) return cached const promise = (async () => { const result = await initializeGpuRenderer({ - forceWebGL: xr != null || forceWebGL, // Supplying `device` makes three skip its own `requestAdapter`, // so R3F's `powerPreference` only reaches the GPU if we forward it. powerPreference: props.powerPreference, @@ -589,10 +548,10 @@ const Viewer = forwardRef(function Viewer( ...(props as any), ...backendParameters, alpha: true, - multiview: xrMultiview, // Allocates the backend's timestamp query pool so // `resolveTimestampsAsync()` can report real GPU render-pass - // time. The WebGL XR backend ignores this WebGPU-only option. + // time (post-processing.tsx). The backend self-disables it + // when the device lacks 'timestamp-query'. trackTimestamp: PERF_OVERLAY_ENABLED, }) renderer.toneMapping = THREE.ACESFilmicToneMapping @@ -603,9 +562,6 @@ const Viewer = forwardRef(function Viewer( }, }) if (result.status === 'ready') { - // XR uses the same WebGL-backed WebGPURenderer as the editor's - // desktop fallback. Empty transient geometries are unsafe in - // both paths because they submit a draw with no position buffer. installEmptyDrawGuard(result.renderer) return result.renderer } @@ -633,152 +589,60 @@ const Viewer = forwardRef(function Viewer( enabled: shadowsEnabled, }} > - - {xr ? ( - - - {children} - - + + + + + + + + + {/* */} + + {useBvh ? ( + + + ) : ( - <> - - - {children} - - + )} - - - - ) -}) - -function ViewerScene({ - children, - disablePostFx, - inputSourceOverlay, - playerModes = false, - hoverStyles, - immersiveXR = false, - onSceneReadyChange, - perf, - sceneReadyKey, - sceneReadyMaxWaitMs, - selectionManager, - useBvh, - xrStore, -}: { - children?: React.ReactNode - disablePostFx: boolean - inputSourceOverlay?: ComponentType<{ type: 'controller' | 'hand' }> - playerModes?: boolean - hoverStyles: HoverStyles - immersiveXR?: boolean - onSceneReadyChange?: (ready: boolean) => void - perf: boolean - sceneReadyKey?: string | number | null - sceneReadyMaxWaitMs?: number - selectionManager: 'default' | 'custom' - useBvh: boolean - xrStore?: ViewerXRStore -}) { - const renderedScene = useBvh ? ( - - - - ) : ( - - ) - const spatialScene = ( - <> - {renderedScene} - - {/* Generic slab-elevation lift for any kind that declares - `capabilities.floorPlaced`. Runs at frame priority 1 so it - lands its mesh.position.y override before the priority-2 - systems below clear the dirty mark. */} - - {/* Generic geometry rebuild loop for any registered kind that - ships `def.geometry`. Reads dirtyNodes, calls the kind's pure - builder, swaps the registered group's children. See - wiki/architecture/node-definitions.md. */} - - {/* Automated stair opening sync — updates slab/ceiling cutouts - whenever stairs, slabs, or levels change. */} - - {/* Mounts systems contributed by registry-backed kinds. Each - kind's `def.system` is loaded via lazy() and rendered here, - ordered by `system.priority`. */} - - {children} - - ) - - return ( - <> - - {immersiveXR && } - - - - - - {/* */} - - {playerModes && xrStore ? ( - - {spatialScene} - - ) : ( - spatialScene - )} - {shouldMountPostProcessingRenderDriver(immersiveXR) && ( + {/* Generic slab-elevation lift for any kind that declares + `capabilities.floorPlaced`. Runs at frame priority 1 so it + lands its mesh.position.y override before the priority-2 + systems below clear the dirty mark. */} + + {/* Generic geometry rebuild loop for any registered kind that + ships `def.geometry`. Reads dirtyNodes, calls the kind's pure + builder, swaps the registered group's children. See + wiki/architecture/node-definitions.md. */} + + {/* Automated stair opening sync — updates slab/ceiling cutouts + whenever stairs, slabs, or levels change. */} + + {/* Mounts systems contributed by registry-backed kinds. Each + kind's `def.system` is loaded via lazy() and rendered here, + ordered by `system.priority`. */} + - )} - {selectionManager === 'default' && } - {(perf || PERF_OVERLAY_ENABLED) && } - {/* Feeds the action-cost ledger the frame's settle state after all - scene systems so a receipt closes when the edit is visible. */} - {(perf || PERF_OVERLAY_ENABLED) && } - {BATCH_SPIKE_ENABLED && } - + {selectionManager === 'default' && } + {(perf || PERF_OVERLAY_ENABLED) && } + {/* Feeds the action-cost ledger the frame's settle state (dirty + queue + deferred wall rebuilds) at a priority after every other + system, so a receipt closes when the user can actually see the + edit. */} + {(perf || PERF_OVERLAY_ENABLED) && } + {BATCH_SPIKE_ENABLED && } + {children} + + ) -} +}) export default Viewer diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index c500cb1b42..1fa7ff5502 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -692,10 +692,6 @@ const PostProcessingPasses = ({ ]) useFrame((_, delta) => { - // The session binding renders with Three's stereo XR camera. Rendering - // this desktop-camera pass during the same frame clears that framebuffer. - if (renderer.xr?.isPresenting) return - if (size.width < 1 || size.height < 1) { return } diff --git a/packages/viewer/src/components/viewer/viewer-camera.test.ts b/packages/viewer/src/components/viewer/viewer-camera.test.ts deleted file mode 100644 index 535c0d1a60..0000000000 --- a/packages/viewer/src/components/viewer/viewer-camera.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { Layers } from 'three' -import { - applyViewerCameraClipping, - enableImmersiveXRViewLayers, - viewerCameraClipping, - viewerUsesPerspectiveCamera, -} from './viewer-camera' - -describe('viewerCameraClipping', () => { - test('uses the WebXR Home clipping range for immersive presentation', () => { - expect(viewerCameraClipping(true)).toEqual({ far: 10_000, near: 0.001 }) - }) - - test('keeps the existing desktop clipping range', () => { - expect(viewerCameraClipping(false)).toEqual({ far: 1000, near: 0.1 }) - }) - - test('XR always uses a perspective camera', () => { - expect(viewerUsesPerspectiveCamera('orthographic', true)).toBe(true) - expect(viewerUsesPerspectiveCamera('perspective', true)).toBe(true) - expect(viewerUsesPerspectiveCamera('orthographic', false)).toBe(false) - }) - - test('XR clipping can be applied to Three’s session camera', () => { - let projectionUpdates = 0 - const camera = { - far: 2000, - near: 0.1, - updateProjectionMatrix: () => { - projectionUpdates += 1 - }, - } - - applyViewerCameraClipping(camera, true) - - expect(camera).toMatchObject({ far: 10_000, near: 0.001 }) - expect(projectionUpdates).toBe(1) - }) - - test('XR enables presentation layers and restores the prior masks', () => { - const cameraLayers = new Layers() - const raycasterLayers = new Layers() - const cameraMask = cameraLayers.mask - const raycasterMask = raycasterLayers.mask - - const restore = enableImmersiveXRViewLayers(cameraLayers, raycasterLayers) - - expect(cameraLayers.mask).not.toBe(cameraMask) - expect(raycasterLayers.mask).not.toBe(raycasterMask) - restore() - expect(cameraLayers.mask).toBe(cameraMask) - expect(raycasterLayers.mask).toBe(raycasterMask) - }) -}) diff --git a/packages/viewer/src/components/viewer/viewer-camera.tsx b/packages/viewer/src/components/viewer/viewer-camera.tsx index ed999fb5e9..adb24e9ff3 100644 --- a/packages/viewer/src/components/viewer/viewer-camera.tsx +++ b/packages/viewer/src/components/viewer/viewer-camera.tsx @@ -1,73 +1,12 @@ import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei' -import { useThree } from '@react-three/fiber' -import { useEffect } from 'react' -import type { Layers } from 'three' -import { GRID_LAYER, OVERLAY_LAYER, ZONE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' -const IMMERSIVE_XR_VISIBLE_LAYERS = [OVERLAY_LAYER, ZONE_LAYER, GRID_LAYER] as const - -export function enableImmersiveXRViewLayers(cameraLayers: Layers, raycasterLayers: Layers) { - const cameraMask = cameraLayers.mask - const raycasterMask = raycasterLayers.mask - for (const layer of IMMERSIVE_XR_VISIBLE_LAYERS) { - cameraLayers.enable(layer) - raycasterLayers.enable(layer) - } - return () => { - cameraLayers.mask = cameraMask - raycasterLayers.mask = raycasterMask - } -} - -function ImmersiveXRViewLayers({ enabled }: { enabled: boolean }) { - const camera = useThree((state) => state.camera) - const raycaster = useThree((state) => state.raycaster) - - useEffect(() => { - if (!enabled) return - return enableImmersiveXRViewLayers(camera.layers, raycaster.layers) - }, [camera, enabled, raycaster]) - - return null -} - -export function viewerCameraClipping(immersiveXR: boolean) { - return immersiveXR ? { far: 10_000, near: 0.001 } : { far: 1000, near: 0.1 } -} - -export function applyViewerCameraClipping( - camera: { far: number; near: number; updateProjectionMatrix(): void }, - immersiveXR: boolean, -) { - const clipping = viewerCameraClipping(immersiveXR) - camera.far = clipping.far - camera.near = clipping.near - camera.updateProjectionMatrix() -} - -export function viewerUsesPerspectiveCamera(cameraMode: string, immersiveXR: boolean) { - return immersiveXR || cameraMode === 'perspective' -} - -export const ViewerCamera = ({ immersiveXR = false }: { immersiveXR?: boolean }) => { +export const ViewerCamera = () => { const cameraMode = useViewer((state) => state.cameraMode) - const clipping = viewerCameraClipping(immersiveXR) - return ( - <> - {viewerUsesPerspectiveCamera(cameraMode, immersiveXR) ? ( - - ) : ( - - )} - - + return cameraMode === 'perspective' ? ( + + ) : ( + ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index dfe04a2e66..5854e2b958 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -12,11 +12,7 @@ export { ErrorBoundary } from './components/error-boundary' // `@pascal-app/nodes//renderer.tsx` and are loaded by the registry // — no per-kind re-exports needed. export { NodeRenderer } from './components/renderers/node-renderer' -export { - default as Viewer, - type ViewerHandle, - type ViewerXRConfig, -} from './components/viewer' +export { default as Viewer, type ViewerHandle } from './components/viewer' export { type BVHEcctrlApi, default as BVHEcctrl, diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx index 8be9d9f6d8..b52e6fcaa5 100644 --- a/packages/viewer/src/lib/renderer-capability.test.tsx +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -14,23 +14,6 @@ function canvasWithContexts(contexts: Partial>) { } describe('GPU renderer capability and initialization', () => { - test('forces WebGL without requesting a WebGPU adapter', async () => { - const requestAdapter = mock(async () => ({ requestDevice: async () => ({}) })) - const createRenderer = mock(() => ({ init: async () => undefined })) - - const result = await initializeGpuRenderer({ - createRenderer, - forceWebGL: true, - gpu: { requestAdapter }, - probeCanvas: canvasWithContexts({ webgl2: {} }), - }) - - expect(result.status).toBe('ready') - if (result.status === 'ready') expect(result.backend).toBe('webgl') - expect(requestAdapter).not.toHaveBeenCalled() - expect(createRenderer).toHaveBeenCalledWith({ forceWebGL: true }) - }) - test('uses a working WebGPU device without requiring WebGL', async () => { const device = {} const createRenderer = mock(() => ({ init: async () => undefined })) diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index 548ac62e34..a528fda857 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -121,14 +121,12 @@ export async function detectRendererCapability({ export async function initializeGpuRenderer({ createRenderer, - forceWebGL = false, gpu, powerPreference, probeCanvas = browserCanvas(), webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { createRenderer: (parameters: RendererBackendParameters) => Renderer - forceWebGL?: boolean gpu?: RendererGpu | null powerPreference?: RendererPowerPreference probeCanvas?: RendererCapabilityCanvas | null @@ -136,7 +134,7 @@ export async function initializeGpuRenderer> { const capability = await detectRendererCapability({ canvas: probeCanvas, - gpu: forceWebGL ? null : gpu, + gpu, powerPreference, webgpuTimeoutMs, }) diff --git a/patches/three@0.185.1.patch b/patches/three@0.185.1.patch deleted file mode 100644 index ac8bdf29ce..0000000000 --- a/patches/three@0.185.1.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..4d46bd53869497fb43debdd56037ecb673c6d85c 100644 ---- a/build/three.webgpu.js -+++ b/build/three.webgpu.js -@@ -14495,1 +14495,5 @@ -- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { -+ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { -+ -+ if ( frame === undefined ) return; -+ -+ const { camera } = frame; -@@ -68378,1 +68382,1 @@ -- if ( renderContext.textures !== null ) { -+ if ( renderContext.textures !== null && framebuffer !== null ) { -diff --git a/build/three.webgpu.nodes.js b/build/three.webgpu.nodes.js -index f707acc24f5711fdae065ce920b08435b0869c1b..270c6824ce277ec0b14a56a34f1f487298e968d5 100644 ---- a/build/three.webgpu.nodes.js -+++ b/build/three.webgpu.nodes.js -@@ -68378,1 +68378,1 @@ -- if ( renderContext.textures !== null ) { -+ if ( renderContext.textures !== null && framebuffer !== null ) { -diff --git a/src/nodes/accessors/Camera.js b/src/nodes/accessors/Camera.js -index 5e89cb93c61f37a1a2d03a27735c74287c0f0e9f..167c7226183d62af9ec61911a742e2445f58a5bc 100644 ---- a/src/nodes/accessors/Camera.js -+++ b/src/nodes/accessors/Camera.js -@@ -318,1 +318,5 @@ -- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { -+ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { -+ -+ if ( frame === undefined ) return; -+ -+ const { camera } = frame; -diff --git a/src/renderers/webgl-fallback/utils/WebGLState.js b/src/renderers/webgl-fallback/utils/WebGLState.js -index adf1eae5944f91aef5964b4754b0c0f7fc9f5cdc..5ffca30b3e4a94be6ca4e3db68ecc03a051d6d3f 100644 ---- a/src/renderers/webgl-fallback/utils/WebGLState.js -+++ b/src/renderers/webgl-fallback/utils/WebGLState.js -@@ -1135,1 +1135,1 @@ -- if ( renderContext.textures !== null ) { -+ if ( renderContext.textures !== null && framebuffer !== null ) { From 8f576074e2ef102168386f1b2f3aae9c12bffb3d Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 11 Sep 2026 13:17:07 +0530 Subject: [PATCH 07/19] feat(nodes): improve duct and pipe wall drawing - Keep larger duct profiles clear of wall faces - Lock runs to their starting wall surface - Remove automatic end caps with deleted runs --- .../nodes/src/duct-segment/draw-plan.test.ts | 18 +++++- .../nodes/src/duct-segment/parametrics.ts | 7 ++- packages/nodes/src/duct-segment/tool.tsx | 22 ++++--- .../nodes/src/pipe-segment/parametrics.ts | 7 ++- .../src/shared/automatic-run-end-cap.test.ts | 23 +++++++ .../nodes/src/shared/automatic-run-end-cap.ts | 17 ++++++ .../src/shared/distribution-run-tool.tsx | 61 +++++++++++++++---- .../shared/fitting-deletion-cleanup.test.ts | 57 +++++++++++++++++ packages/nodes/src/shared/run-cursor.test.ts | 39 +++++++++++- 9 files changed, 224 insertions(+), 27 deletions(-) diff --git a/packages/nodes/src/duct-segment/draw-plan.test.ts b/packages/nodes/src/duct-segment/draw-plan.test.ts index 5e95021141..8ad16ab6e7 100644 --- a/packages/nodes/src/duct-segment/draw-plan.test.ts +++ b/packages/nodes/src/duct-segment/draw-plan.test.ts @@ -1,8 +1,24 @@ import { expect, test } from 'bun:test' import { DuctSegmentNode, useScene } from '@pascal-app/core' -import { planDuctDraw } from './tool' +import { ductSurfaceClearanceM, planDuctDraw } from './tool' const profile = { shape: 'round' as const, diameter: 6, width: 12, height: 8 } + +test('keeps rectangular and oval ducts outside wall faces using their largest dimension', () => { + expect(ductSurfaceClearanceM({ shape: 'round', diameter: 6, width: 12, height: 8 })).toBeCloseTo( + 0.0762, + ) + expect(ductSurfaceClearanceM({ shape: 'rect', diameter: 6, width: 14, height: 8 })).toBeCloseTo( + 0.1778, + ) + expect( + ductSurfaceClearanceM({ shape: 'rect', diameter: 6, width: 14, height: 8 }, true), + ).toBeCloseTo(0.1878) + expect( + ductSurfaceClearanceM({ shape: 'oval', diameter: 6, width: 14, height: 8 }, true), + ).toBeCloseTo(0.1878) +}) + test('a short existing run cannot silently lose its required elbow', () => { const node = DuctSegmentNode.parse({ path: [ diff --git a/packages/nodes/src/duct-segment/parametrics.ts b/packages/nodes/src/duct-segment/parametrics.ts index 8406d681ef..4a4fbfec5f 100644 --- a/packages/nodes/src/duct-segment/parametrics.ts +++ b/packages/nodes/src/duct-segment/parametrics.ts @@ -1,6 +1,7 @@ import { type DuctFittingNode, type ParametricDescriptor, useScene } from '@pascal-app/core' import { Vector3 } from 'three' import { getDuctFittingPorts } from '../duct-fitting/ports' +import { findAutomaticRunEndCapIds } from '../shared/automatic-run-end-cap' import { fittingDeletionPlansForRun } from '../shared/fitting-deletion-cleanup' import { rollToContinueAcrossElbow } from './geometry' import type { DuctSegmentNode } from './schema' @@ -105,10 +106,12 @@ export const ductSegmentParametrics: ParametricDescriptor = { fittingDeletionPlansForRun(duct, nodes, requestedDeleteIds, true).flatMap( (plan) => plan.updates, ), - onDeleteCascade: (duct, nodes, _pendingDeleteIds, requestedDeleteIds) => - fittingDeletionPlansForRun(duct, nodes, requestedDeleteIds, false).flatMap((plan) => + onDeleteCascade: (duct, nodes, _pendingDeleteIds, requestedDeleteIds) => [ + ...findAutomaticRunEndCapIds(duct.id, nodes, 'duct-fitting'), + ...fittingDeletionPlansForRun(duct, nodes, requestedDeleteIds, false).flatMap((plan) => plan.deleteFitting ? [plan.fittingId, ...plan.cascadeDeleteIds] : [], ), + ], trailingSection: () => import('../shared/run-hanger-inspector'), groups: [ { diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index 12691ff135..5908d8ff77 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -85,6 +85,7 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry' */ const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const const BODY_SNAP_RADIUS_M = 0.35 +const DUCT_WALL_STANDOFF_M = 0.01 /** Angle step (radians) for the XZ angle lock — 45°. */ /** @@ -188,6 +189,15 @@ type DraftProfile = { height: number } +/** Half the largest profile dimension, used to keep a duct clear of a wall. */ +export function ductSurfaceClearanceM(profile: DraftProfile, wall = false): number { + return ( + runSectionHalfSizeM( + profile.shape === 'round' ? profile.diameter : Math.max(profile.width, profile.height), + ) + (wall && profile.shape !== 'round' ? DUCT_WALL_STANDOFF_M : 0) + ) +} + /** * Profile to inherit when the segment start snaps onto `port` — joining * means continuing that thing: a rect trunk end keeps its W×H, a round @@ -553,13 +563,7 @@ const DuctSegmentTool = () => { findBody: (point) => findNearestRunBody3D(point, BODY_SNAP_RADIUS_M, { levelId: activeLevelId ?? undefined }), surfaceClearance: (surface) => - surface - ? runSectionHalfSizeM( - profileRef.current.shape === 'round' - ? profileRef.current.diameter - : profileRef.current.height, - ) - : 0, + surface ? ductSurfaceClearanceM(profileRef.current, surface.kind === 'wall') : 0, minimumSegmentLength: 0.08, inheritFromConnection: ({ port }) => { if (!port) return @@ -593,9 +597,7 @@ const DuctSegmentTool = () => { node.path[0]!, node.path.at(-1)!, surfaceTarget, - profileRef.current.shape === 'round' - ? runSectionHalfSizeM(profileRef.current.diameter) - : runSectionHalfSizeM(profileRef.current.height), + ductSurfaceClearanceM(profileRef.current, true), ) : undefined return { ...node, wallAttachment } diff --git a/packages/nodes/src/pipe-segment/parametrics.ts b/packages/nodes/src/pipe-segment/parametrics.ts index 9f2adcd43f..33e165a830 100644 --- a/packages/nodes/src/pipe-segment/parametrics.ts +++ b/packages/nodes/src/pipe-segment/parametrics.ts @@ -1,4 +1,5 @@ import type { ParametricDescriptor } from '@pascal-app/core' +import { findAutomaticRunEndCapIds } from '../shared/automatic-run-end-cap' import { fittingDeletionPlansForRun } from '../shared/fitting-deletion-cleanup' import type { PipeSegmentNode } from './schema' @@ -15,10 +16,12 @@ export const pipeSegmentParametrics: ParametricDescriptor = { fittingDeletionPlansForRun(pipe, nodes, requestedDeleteIds, true).flatMap( (plan) => plan.updates, ), - onDeleteCascade: (pipe, nodes, _pendingDeleteIds, requestedDeleteIds) => - fittingDeletionPlansForRun(pipe, nodes, requestedDeleteIds, false).flatMap((plan) => + onDeleteCascade: (pipe, nodes, _pendingDeleteIds, requestedDeleteIds) => [ + ...findAutomaticRunEndCapIds(pipe.id, nodes, 'pipe-fitting'), + ...fittingDeletionPlansForRun(pipe, nodes, requestedDeleteIds, false).flatMap((plan) => plan.deleteFitting ? [plan.fittingId, ...plan.cascadeDeleteIds] : [], ), + ], trailingSection: () => import('../shared/run-hanger-inspector'), groups: [ { diff --git a/packages/nodes/src/shared/automatic-run-end-cap.test.ts b/packages/nodes/src/shared/automatic-run-end-cap.test.ts index 01a7be432f..7a8c80ac43 100644 --- a/packages/nodes/src/shared/automatic-run-end-cap.test.ts +++ b/packages/nodes/src/shared/automatic-run-end-cap.test.ts @@ -4,6 +4,7 @@ import { DuctSegmentNode, loadPlugin, nodeRegistry, + PipeFittingNode, PipeSegmentNode, } from '@pascal-app/core' import { getDuctFittingPorts } from '../duct-fitting/ports' @@ -12,6 +13,7 @@ import { getPipeFittingPorts } from '../pipe-fitting/ports' import { createDuctRunEndCap, createPipeRunEndCap, + findAutomaticRunEndCapIds, findMatedRunEndCapIds, isRunEndCapPort, planRunEndCapFollowUpdates, @@ -154,6 +156,27 @@ describe('automatic run end caps', () => { ).toEqual([firstCap.id]) }) + test('finds automatic caps by run owner without matching manual caps', () => { + const pipe = PipeSegmentNode.parse({ + path: [ + [0, 1, 0], + [3, 1, 0], + ], + }) + const automatic = createPipeRunEndCap(pipe)! + const manual = PipeFittingNode.parse({ + fittingType: 'end-cap', + metadata: {}, + }) + const nodes = { + [pipe.id]: pipe, + [automatic.id]: automatic, + [manual.id]: manual, + } as Record + + expect(findAutomaticRunEndCapIds(pipe.id, nodes, 'pipe-fitting')).toEqual([automatic.id]) + }) + test.each([ [ 'duct', diff --git a/packages/nodes/src/shared/automatic-run-end-cap.ts b/packages/nodes/src/shared/automatic-run-end-cap.ts index 27011f0ce6..2186ea3c05 100644 --- a/packages/nodes/src/shared/automatic-run-end-cap.ts +++ b/packages/nodes/src/shared/automatic-run-end-cap.ts @@ -167,6 +167,23 @@ export function findMatedRunEndCapIds( return ids } +export function findAutomaticRunEndCapIds( + runId: AnyNodeId, + nodes: Readonly>, + fittingKind: 'duct-fitting' | 'pipe-fitting', +): AnyNodeId[] { + return Object.values(nodes).flatMap((node) => { + if ( + !node || + node.type !== fittingKind || + node.fittingType !== 'end-cap' || + node.metadata[END_CAP_OWNER_ID_KEY] !== runId + ) + return [] + return [node.id] + }) +} + export function planRunEndCapFollowUpdates( originalRun: DuctSegmentNode | PipeSegmentNode, nextRun: DuctSegmentNode | PipeSegmentNode, diff --git a/packages/nodes/src/shared/distribution-run-tool.tsx b/packages/nodes/src/shared/distribution-run-tool.tsx index ef206c5bdd..de2ba9d121 100644 --- a/packages/nodes/src/shared/distribution-run-tool.tsx +++ b/packages/nodes/src/shared/distribution-run-tool.tsx @@ -74,6 +74,27 @@ type ResolvedRunPoint = RunConnection & { directionMode: RunDirectionMode } +type RunWallSurfaceTarget = Extract + +export function isSameRunWallSurface( + target: RunWallSurfaceTarget, + hit: + | { + kind?: string + hostId?: string + face?: string + side?: string + } + | undefined, +): boolean { + return ( + hit?.kind === 'wall' && + hit.hostId === target.hostId && + hit.face === 'side' && + hit.side === target.side + ) +} + const UP: RunPoint = [0, 1, 0] const X_AXIS: RunPoint = [1, 0, 0] const Z_AXIS: RunPoint = [0, 0, 1] @@ -525,6 +546,8 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const refreshCursorRef = useRef<() => void>(() => {}) const lastClientYRef = useRef(null) const lastResolvedRef = useRef(null) + const lockedWallTargetRef = useRef(null) + const lockedWallFrameRef = useRef(null) const forcedDirectionRef = useRef(null) const hoveredDirectionRef = useRef(null) const lengthInputRef = useRef('') @@ -593,18 +616,26 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const hit = surfacePointFromEvent(event, adapter.levelId) const currentStart = startRef.current const previous = lastResolvedRef.current - // A wall is only an attachment candidate, not a constraint for the - // whole run. Once the ray leaves the wall, continue on a horizontal - // plane through the start point so the user can route freely at the - // same elevation (or use angle lock for a deliberate diagonal). - const working = - !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart - ? createRunSurfaceFrame(currentStart, UP) - : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null)) + const lockedWallTarget = currentStart ? lockedWallTargetRef.current : null + const lockedWallHit = + lockedWallTarget && isSameRunWallSurface(lockedWallTarget, event.surfaceHit) + const working = lockedWallTarget + ? (lockedWallFrameRef.current ?? + (currentStart + ? createRunSurfaceFrame(currentStart, lockedWallTarget.frame.normal) + : null)) + : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null)) const hasSurface = !!event.surfaceHit || !working || !event.localRay - const target = hasSurface ? hit.target : null + const target = lockedWallTarget ?? (hasSurface ? hit.target : null) + const hitForCursor = lockedWallTarget + ? lockedWallHit + ? { point: hit.point, frame: lockedWallTarget.frame } + : null + : hasSurface + ? { point: hit.point, frame: hit.frame } + : null const resolved = resolveRunCursorPlane({ - hit: hasSurface ? { point: hit.point, frame: hit.frame } : null, + hit: hitForCursor, working, ray: event.localRay, fallback: previous?.point ?? currentStart ?? hit.point, @@ -795,7 +826,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { resolved = { ...resolved, frame: { ...lastResolvedRef.current.frame, origin: resolved.point }, - surfaceTarget: null, + surfaceTarget: lockedWallTargetRef.current, } lastResolvedRef.current = resolved const currentStart = startRef.current @@ -925,6 +956,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const resolved = applyTypedLength(resolvePoint(event)) updateCursor(resolved) if (!currentStart) { + if (resolved.surfaceTarget?.kind === 'wall') { + lockedWallTargetRef.current = resolved.surfaceTarget + lockedWallFrameRef.current = resolved.frame ?? null + } triggerSFX('sfx:grid-snap') const connection = { port: resolved.port, @@ -1031,6 +1066,8 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { } const onCancel = () => { + lockedWallTargetRef.current = null + lockedWallFrameRef.current = null clearDrawAlignment() if (!startRef.current) return markToolCancelConsumed() @@ -1081,6 +1118,8 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { window.removeEventListener('keyup', onKeyUp) altAnchorRef.current = null lastPointerRef.current = null + lockedWallTargetRef.current = null + lockedWallFrameRef.current = null refreshCursorRef.current = () => {} clearPlacementSurface() clearDrawAlignment() diff --git a/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts b/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts index 36a6bf6a84..57394d0c0f 100644 --- a/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts +++ b/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts @@ -15,6 +15,7 @@ import { pipeFittingDefinition } from '../pipe-fitting/definition' import { getPipeFittingPorts } from '../pipe-fitting/ports' import { pipeSegmentDefinition } from '../pipe-segment/definition' import { PipeSegmentNode } from '../pipe-segment/schema' +import { createDuctRunEndCap, createPipeRunEndCap } from './automatic-run-end-cap' type RafFn = (callback: (time: number) => void) => number ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( @@ -109,6 +110,62 @@ function expectRunStartsOnPorts( } describe('fitting cleanup when a connected run is deleted', () => { + test('deleting a duct removes its automatic end cap but keeps a manual cap', () => { + withDistributionDefinitions(() => { + const duct = DuctSegmentNode.parse({ + ...ductSegmentDefinition.defaults(), + id: 'duct-segment_end-cap-delete', + path: [ + [0, 1, 0], + [3, 1, 0], + ], + }) + const automaticCap = createDuctRunEndCap(duct)! + const manualCap = DuctFittingNode.parse({ + ...ductFittingDefinition.defaults(), + id: 'duct-fitting_manual-end-cap', + fittingType: 'end-cap', + }) + useScene.setState({ + nodes: { + [duct.id]: duct, + [automaticCap.id]: automaticCap, + [manualCap.id]: manualCap, + }, + rootNodeIds: [duct.id, automaticCap.id, manualCap.id], + readOnly: false, + } as never) + + useScene.getState().deleteNode(duct.id) + + expect(useScene.getState().nodes[automaticCap.id]).toBeUndefined() + expect(useScene.getState().nodes[manualCap.id]).toBeDefined() + }) + }) + + test('deleting a pipe removes its automatic end cap', () => { + withDistributionDefinitions(() => { + const pipe = PipeSegmentNode.parse({ + ...pipeSegmentDefinition.defaults(), + id: 'pipe-segment_end-cap-delete', + path: [ + [0, 1, 0], + [3, 1, 0], + ], + }) + const automaticCap = createPipeRunEndCap(pipe)! + useScene.setState({ + nodes: { [pipe.id]: pipe, [automaticCap.id]: automaticCap }, + rootNodeIds: [pipe.id, automaticCap.id], + readOnly: false, + } as never) + + useScene.getState().deleteNode(pipe.id) + + expect(useScene.getState().nodes[automaticCap.id]).toBeUndefined() + }) + }) + test('downgrades a duct cross to a tee and keeps every surviving collar mated', () => { withDistributionDefinitions(() => { const fitting = DuctFittingNode.parse({ diff --git a/packages/nodes/src/shared/run-cursor.test.ts b/packages/nodes/src/shared/run-cursor.test.ts index 5a4bdb109f..7e3b83e6cb 100644 --- a/packages/nodes/src/shared/run-cursor.test.ts +++ b/packages/nodes/src/shared/run-cursor.test.ts @@ -1,8 +1,45 @@ import { describe, expect, test } from 'bun:test' -import { createRunSurfaceFrame } from './distribution-run-tool' +import type { AnyNodeId } from '@pascal-app/core' +import { createRunSurfaceFrame, isSameRunWallSurface } from './distribution-run-tool' import { intersectRunPlane, resolveRunCursorPlane } from './run-cursor' describe('surface-first run cursor', () => { + test('matches only the wall host and face that started the draft', () => { + const target = { + kind: 'wall' as const, + levelId: 'level-1' as AnyNodeId, + hostId: 'wall-1' as AnyNodeId, + side: 'front' as const, + frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]), + bounds: { min: { x: -1, y: -1 }, max: { x: 1, y: 1 } }, + } + + expect( + isSameRunWallSurface(target, { + kind: 'wall', + hostId: target.hostId, + face: 'side', + side: 'front', + }), + ).toBe(true) + expect( + isSameRunWallSurface(target, { + kind: 'wall', + hostId: target.hostId, + face: 'side', + side: 'back', + }), + ).toBe(false) + expect( + isSameRunWallSurface(target, { + kind: 'wall', + hostId: 'wall-2', + face: 'side', + side: 'front', + }), + ).toBe(false) + }) + test('reacquires either ceiling face from free space with duct or pipe clearance', () => { for (const side of [-1, 1]) { for (const clearance of [0.0254, 0.1016]) { From ab2d9839d06af0b70082318ceb4f07737eec2b06 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 13 Sep 2026 15:48:27 +0530 Subject: [PATCH 08/19] fix(editor): improve parametric inspector controls - Support default-expanded inspector groups and grid action buttons - Fix switch styling in the editor - Disable incompatible React Scan bootstrap loading --- apps/editor/app/client-bootstrap.tsx | 17 +++++------------ apps/editor/app/globals.css | 16 ++++++++++++++++ apps/editor/app/layout.tsx | 2 +- packages/core/src/registry/types.ts | 2 ++ .../ui/panels/multi-parametric-inspector.tsx | 5 ++++- .../ui/panels/parametric-inspector.tsx | 4 ++-- .../src/components/ui/primitives/switch.tsx | 4 ++-- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index 821544fec6..b97835cfcf 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -9,18 +9,11 @@ // `loaded` guard inside `../lib/bootstrap` keeps the side effect // idempotent under HMR. import '../lib/bootstrap' -import { type ReactNode, useEffect } from 'react' +import type { ReactNode } from 'react' -export function ClientBootstrap({ - children, - enableDevDiagnostics, -}: { - children: ReactNode - enableDevDiagnostics: boolean -}) { - useEffect(() => { - if (!enableDevDiagnostics) return - import('react-scan').then(({ scan }) => scan({ enabled: true })) - }, [enableDevDiagnostics]) +export function ClientBootstrap({ children }: { children: ReactNode }) { + // React Scan is an optional diagnostic and currently has an ESM/CommonJS + // package.json export mismatch under the editor's webpack dev build. + // Keep the bootstrap side-effect free until the host dependency is aligned. return children } diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 77fd6e1d02..ea5f097ef9 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -372,3 +372,19 @@ 100% 100%; } } + +.pascal-switch[data-state='checked'] { + background-color: var(--primary); +} + +.pascal-switch[data-state='unchecked'] { + background-color: var(--input); +} + +.pascal-switch[data-state='checked'] .pascal-switch-thumb { + transform: translateX(1rem); +} + +.pascal-switch[data-state='unchecked'] .pascal-switch-thumb { + transform: translateX(0); +} diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 01965f4ba3..e19f22a6fe 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -35,7 +35,7 @@ export default function RootLayout({ lang="en" > - {children} + {children} {enableDevDiagnostics && } diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 69ac14c229..18333405ba 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -2455,6 +2455,8 @@ export type ParamAction = { export type ParamGroup = { label: string fields: ParamField[] + /** Whether this inspector group is open when it is first rendered. */ + defaultExpanded?: boolean } export type ParamField = diff --git a/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx b/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx index bab0b2ace8..2a85472973 100644 --- a/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx @@ -72,6 +72,7 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode )} {parametrics.groups.map((group, gi) => ( []} key={`group-${gi}`} nodeIds={nodeIds} @@ -88,12 +89,14 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode } function MultiGroupFields({ + defaultExpanded, title, fields, nodeIds, nodeType, parametrics, }: { + defaultExpanded?: boolean title: string fields: ParamField[] nodeIds: AnyNodeId[] @@ -108,7 +111,7 @@ function MultiGroupFields({ ) if (genericFields.length === 0 || !anyVisible) return null return ( - + {genericFields.map((field, fi) => { if ( String(field.key) === 'height' && diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index b1bc2132ed..b2c72cbd3e 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -157,7 +157,7 @@ export function ParametricInspector({ width={320} > {parametrics.groups.map((group, gi) => ( - + {group.fields.map((field, fi) => ( 0)) && ( - + {canMove && ( } label="Move" onClick={handleMove} /> )} diff --git a/packages/editor/src/components/ui/primitives/switch.tsx b/packages/editor/src/components/ui/primitives/switch.tsx index a6af56c330..e34b1b8ff4 100644 --- a/packages/editor/src/components/ui/primitives/switch.tsx +++ b/packages/editor/src/components/ui/primitives/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( From e9e2820fb97a421bb40107a4bd2f0119ea6076af Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 15 Sep 2026 13:47:21 +0530 Subject: [PATCH 09/19] build: pin pool plugin to github commit --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index d60a2f7ab0..d49e641fd6 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#646d9fc761da4ce65336682702e8e8b76f051b3b", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", diff --git a/bun.lock b/bun.lock index b63a089de4..2dfa357e33 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#646d9fc761da4ce65336682702e8e8b76f051b3b", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", @@ -753,7 +753,7 @@ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="], - "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sha512-nu/7x5xF1k0IYnduNu1rh9YJ9hZrmjaRUiXADcDqF82Kc+GLjZFrPRRHCci37QplREY2lqePIXBYfTOXCQczgg=="], + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#646d9fc", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-646d9fc", "sha512-KeVFZ+n5Hrmv4nJhp0uzZ5XXJ8h4ddaEoGKBhFrfGt8Zlf6OVk60/eQFvp2bUuVOhXUGqe1Vdgk/XGTnapG3PQ=="], "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="], From bde47fe81049b8943c483fa4d4a5d673b29ef12f Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 15 Sep 2026 14:05:33 +0530 Subject: [PATCH 10/19] fix: pin pool plugin typecheck fix --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index d49e641fd6..7b405b72bf 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#646d9fc761da4ce65336682702e8e8b76f051b3b", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#6ee417c", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", diff --git a/bun.lock b/bun.lock index 2dfa357e33..ddd09870d2 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#646d9fc761da4ce65336682702e8e8b76f051b3b", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#6ee417c", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", @@ -753,7 +753,7 @@ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="], - "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#646d9fc", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-646d9fc", "sha512-KeVFZ+n5Hrmv4nJhp0uzZ5XXJ8h4ddaEoGKBhFrfGt8Zlf6OVk60/eQFvp2bUuVOhXUGqe1Vdgk/XGTnapG3PQ=="], + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#6ee417c", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-6ee417c", "sha512-x0bfYueNZOiHwYHGxNLxLEW19TbB6ULy6GzVebSs4oJJq8zGZ1prSCtVNs07GNsjyMWDQtrw43MiJR+Qjt81lA=="], "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="], From 1424ed9630d1c6eb43f87635ac226d51e0dd4b52 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 15 Sep 2026 14:09:43 +0530 Subject: [PATCH 11/19] fix(nodes): keep distribution runs on intended wall plane --- .../src/shared/distribution-run-tool.tsx | 36 ++++++++++++------- packages/nodes/src/shared/run-cursor.test.ts | 21 ++++++++++- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/nodes/src/shared/distribution-run-tool.tsx b/packages/nodes/src/shared/distribution-run-tool.tsx index de2ba9d121..871711ea52 100644 --- a/packages/nodes/src/shared/distribution-run-tool.tsx +++ b/packages/nodes/src/shared/distribution-run-tool.tsx @@ -76,6 +76,13 @@ type ResolvedRunPoint = RunConnection & { type RunWallSurfaceTarget = Extract +export function shouldLockRunWallSurface( + target: RunSurfaceTarget | null | undefined, + connection: RunConnection, +): target is RunWallSurfaceTarget { + return target?.kind === 'wall' && !connection.port && !connection.body +} + export function isSameRunWallSurface( target: RunWallSurfaceTarget, hit: @@ -547,7 +554,6 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const lastClientYRef = useRef(null) const lastResolvedRef = useRef(null) const lockedWallTargetRef = useRef(null) - const lockedWallFrameRef = useRef(null) const forcedDirectionRef = useRef(null) const hoveredDirectionRef = useRef(null) const lengthInputRef = useRef('') @@ -620,11 +626,18 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const lockedWallHit = lockedWallTarget && isSameRunWallSurface(lockedWallTarget, event.surfaceHit) const working = lockedWallTarget - ? (lockedWallFrameRef.current ?? - (currentStart - ? createRunSurfaceFrame(currentStart, lockedWallTarget.frame.normal) - : null)) - : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null)) + ? { + ...lockedWallTarget.frame, + origin: lockedWallTarget.frame.origin.map( + (value, index) => + value + + lockedWallTarget.frame.normal[index]! * + (adapter.surfaceClearance?.(lockedWallTarget) ?? 0), + ) as RunPoint, + } + : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart + ? createRunSurfaceFrame(currentStart, UP) + : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null)) const hasSurface = !!event.surfaceHit || !working || !event.localRay const target = lockedWallTarget ?? (hasSurface ? hit.target : null) const hitForCursor = lockedWallTarget @@ -956,15 +969,14 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const resolved = applyTypedLength(resolvePoint(event)) updateCursor(resolved) if (!currentStart) { - if (resolved.surfaceTarget?.kind === 'wall') { - lockedWallTargetRef.current = resolved.surfaceTarget - lockedWallFrameRef.current = resolved.frame ?? null - } - triggerSFX('sfx:grid-snap') const connection = { port: resolved.port, body: resolved.port ? null : resolved.body, } + if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) { + lockedWallTargetRef.current = resolved.surfaceTarget + } + triggerSFX('sfx:grid-snap') startConnectionRef.current = connection configRef.current.inheritFromConnection?.(connection) startRef.current = resolved.point @@ -1067,7 +1079,6 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const onCancel = () => { lockedWallTargetRef.current = null - lockedWallFrameRef.current = null clearDrawAlignment() if (!startRef.current) return markToolCancelConsumed() @@ -1119,7 +1130,6 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { altAnchorRef.current = null lastPointerRef.current = null lockedWallTargetRef.current = null - lockedWallFrameRef.current = null refreshCursorRef.current = () => {} clearPlacementSurface() clearDrawAlignment() diff --git a/packages/nodes/src/shared/run-cursor.test.ts b/packages/nodes/src/shared/run-cursor.test.ts index 7e3b83e6cb..8d20019626 100644 --- a/packages/nodes/src/shared/run-cursor.test.ts +++ b/packages/nodes/src/shared/run-cursor.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' import type { AnyNodeId } from '@pascal-app/core' -import { createRunSurfaceFrame, isSameRunWallSurface } from './distribution-run-tool' +import { + createRunSurfaceFrame, + isSameRunWallSurface, + shouldLockRunWallSurface, +} from './distribution-run-tool' import { intersectRunPlane, resolveRunCursorPlane } from './run-cursor' describe('surface-first run cursor', () => { @@ -40,6 +44,21 @@ describe('surface-first run cursor', () => { ).toBe(false) }) + test('locks a wall only when the start is not snapped to a body or port', () => { + const target = { + kind: 'wall' as const, + levelId: 'level-1' as AnyNodeId, + hostId: 'wall-1' as AnyNodeId, + side: 'front' as const, + frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]), + bounds: { minU: 0, maxU: 1, minV: 0, maxV: 1 }, + } + + expect(shouldLockRunWallSurface(target, { port: null, body: null })).toBe(true) + expect(shouldLockRunWallSurface(target, { port: {} as never, body: null })).toBe(false) + expect(shouldLockRunWallSurface(target, { port: null, body: {} as never })).toBe(false) + }) + test('reacquires either ceiling face from free space with duct or pipe clearance', () => { for (const side of [-1, 1]) { for (const clearance of [0.0254, 0.1016]) { From c02bca2a61dc6c274772aa50df40fa56bb052c46 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 15 Sep 2026 14:32:50 +0530 Subject: [PATCH 12/19] fix: update pool plugin to latest commit --- apps/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 7b405b72bf..d49e641fd6 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#6ee417c", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#646d9fc761da4ce65336682702e8e8b76f051b3b", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", From cd436b268a11c79c9f23ccb3deaec79626c770cb Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 15 Sep 2026 14:42:30 +0530 Subject: [PATCH 13/19] fix: pin pool plugin to type-safe latest commit --- apps/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index d49e641fd6..7b405b72bf 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#646d9fc761da4ce65336682702e8e8b76f051b3b", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#6ee417c", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", From a721d13ad544ba5b28394a97f63f6ba16bd744fb Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 15 Sep 2026 18:40:34 +0530 Subject: [PATCH 14/19] fix(editor): restore portable switch styling --- apps/editor/app/client-bootstrap.tsx | 17 ++++++++++++----- apps/editor/app/globals.css | 16 ---------------- apps/editor/app/layout.tsx | 2 +- apps/editor/package.json | 2 +- ...pp-plugin-pool-0.1.0-connection-status.tgz | Bin 3128590 -> 0 bytes bun.lock | 2 +- .../editor/src/components/editor/index.tsx | 1 + .../src/components/ui/primitives/switch.tsx | 4 ++-- 8 files changed, 18 insertions(+), 26 deletions(-) delete mode 100644 apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index b97835cfcf..821544fec6 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -9,11 +9,18 @@ // `loaded` guard inside `../lib/bootstrap` keeps the side effect // idempotent under HMR. import '../lib/bootstrap' -import type { ReactNode } from 'react' +import { type ReactNode, useEffect } from 'react' -export function ClientBootstrap({ children }: { children: ReactNode }) { - // React Scan is an optional diagnostic and currently has an ESM/CommonJS - // package.json export mismatch under the editor's webpack dev build. - // Keep the bootstrap side-effect free until the host dependency is aligned. +export function ClientBootstrap({ + children, + enableDevDiagnostics, +}: { + children: ReactNode + enableDevDiagnostics: boolean +}) { + useEffect(() => { + if (!enableDevDiagnostics) return + import('react-scan').then(({ scan }) => scan({ enabled: true })) + }, [enableDevDiagnostics]) return children } diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index ea5f097ef9..77fd6e1d02 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -372,19 +372,3 @@ 100% 100%; } } - -.pascal-switch[data-state='checked'] { - background-color: var(--primary); -} - -.pascal-switch[data-state='unchecked'] { - background-color: var(--input); -} - -.pascal-switch[data-state='checked'] .pascal-switch-thumb { - transform: translateX(1rem); -} - -.pascal-switch[data-state='unchecked'] .pascal-switch-thumb { - transform: translateX(0); -} diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index e19f22a6fe..01965f4ba3 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -35,7 +35,7 @@ export default function RootLayout({ lang="en" > - {children} + {children} {enableDevDiagnostics && } diff --git a/apps/editor/package.json b/apps/editor/package.json index 7b405b72bf..c3f70f9276 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#6ee417c", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#6ee417c88fa0f01057b654d2417abbebeb6c3436", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", diff --git a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz deleted file mode 100644 index 4461b548cb1ea81b5e55dd2ff013e21f60200860..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3128590 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%0AApoD+PQCkZ%7LNK0Ysd@O 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

FpPu0Fj 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