UNPKG

@playcanvas/react

Version:

A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.

157 lines 6.75 kB
import { Picker } from "playcanvas"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { SyntheticMouseEvent, SyntheticPointerEvent } from "./synthetic-event.js"; // Utility to propagate events up the entity hierarchy const propagateEvent = (entity, event, stopAt = null) => { while (entity) { if (entity === stopAt) return false; entity.fire(event.type, event); if (event.hasStoppedPropagation) return true; entity = entity.parent; } return false; }; const getNearestCommonAncestor = (a, b) => { const ancestors = new Set(); // Traverse up the parent chain of entity 'a' and add each ancestor to the set let current = a; while (current) { ancestors.add(current); current = current.parent; } // Traverse up the parent chain of entity 'b' and check against the set current = b; while (current) { if (ancestors.has(current)) { return current; // Found the nearest common ancestor } current = current.parent; } return null; // No common ancestor found }; const getEntityAtPointerEvent = async (app, picker, rect, e) => { // Find the highest priority camera const [activeCamera] = app.root.findComponents('camera') .filter((camera) => !camera.renderTarget) .sort((a, b) => a.priority - b.priority); if (!activeCamera) return null; // Get canvas bounds const canvas = app.graphicsDevice.canvas; if (!canvas || canvas.width === 0 || canvas.height === 0) return null; // Calculate position relative to canvas const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Ignore events outside the canvas bounds to avoid unnecessary picker work if (x < 0 || y < 0 || x > rect.width || y > rect.height) { return null; } // Scale calculation using PlayCanvas's DPR const scaleX = canvas.width / (rect.width * app.graphicsDevice.maxPixelRatio); const scaleY = canvas.height / (rect.height * app.graphicsDevice.maxPixelRatio); // prepare the picker and perform picking try { picker.prepare(activeCamera, app.scene); const [meshInstance] = await picker.getSelectionAsync(x * scaleX, y * scaleY); if (!meshInstance) return null; return meshInstance?.node; } catch { // The picker can fail if the camera is not active or the canvas is not visible return null; } }; export const usePicker = (app, el, pointerEvents) => { const activeEntity = useRef(null); const pointerDetails = useRef(null); const canvasRectRef = useRef(app ? app.graphicsDevice.canvas.getBoundingClientRect() : null); // Construct a Global Picker const picker = useMemo(() => { if (!app || !app.graphicsDevice) return null; return new Picker(app, app.graphicsDevice.width, app.graphicsDevice.height); }, [app]); // Watch for the canvas to resize. Necessary for correct picking useEffect(() => { const resizeObserver = new ResizeObserver(() => { canvasRectRef.current = app ? app.graphicsDevice.canvas.getBoundingClientRect() : null; if (canvasRectRef.current) { picker?.resize(canvasRectRef.current.width, canvasRectRef.current.height); } }); if (app?.graphicsDevice?.canvas) resizeObserver.observe(app.graphicsDevice.canvas); return () => resizeObserver.disconnect(); }, [app]); // Store pointer position const onPointerMove = useCallback((e) => { pointerDetails.current = e; }, [picker]); const onFrameUpdate = useCallback(async () => { if (pointerEvents.size === 0) { // No listeners: clear hover state to avoid stale pointerout on re-enable activeEntity.current = null; return; } const e = pointerDetails.current; if (!picker || !app || !e) return null; if (!canvasRectRef.current) return null; const entity = await getEntityAtPointerEvent(app, picker, canvasRectRef.current, e); // if (!entity) return null; const prevEntity = activeEntity.current; // Find the common ancestor of the current target and last event. We do not need to bubble past this const stopBubblingAt = getNearestCommonAncestor(prevEntity, entity); // If the pointer moves out of the current hovered entity (and its children) if (prevEntity && prevEntity !== entity) { const pointerOutEvent = new SyntheticPointerEvent(e); pointerOutEvent.type = 'pointerout'; propagateEvent(prevEntity, pointerOutEvent, stopBubblingAt); } // If the pointer moves over a new entity if (entity && entity !== prevEntity) { const pointerOverEvent = new SyntheticPointerEvent(e); pointerOverEvent.type = 'pointerover'; propagateEvent(entity, pointerOverEvent, stopBubblingAt); } // Update our reference activeEntity.current = entity; return null; }, [picker, pointerEvents]); // Construct a generic handler for pointer events const onInteractionEvent = useCallback(async (e) => { if (!picker || !app || !canvasRectRef.current || pointerEvents.size === 0) return; const entity = await getEntityAtPointerEvent(app, picker, canvasRectRef.current, e); if (!entity) return; // Handle other pointer events (down, up, move) const syntheticEvent = e instanceof PointerEvent ? new SyntheticPointerEvent(e) : new SyntheticMouseEvent(e); propagateEvent(entity, syntheticEvent); }, [picker, pointerEvents]); useLayoutEffect(() => { if (!picker || !el || !app) return; el.addEventListener('pointerup', onInteractionEvent); el.addEventListener('pointerdown', onInteractionEvent); el.addEventListener('mouseup', onInteractionEvent); el.addEventListener('click', onInteractionEvent); el.addEventListener('pointermove', onPointerMove); app.on('update', onFrameUpdate); return () => { el.removeEventListener('pointerup', onInteractionEvent); el.removeEventListener('pointerdown', onInteractionEvent); el.removeEventListener('click', onInteractionEvent); el.removeEventListener('pointermove', onPointerMove); app.off('update', onFrameUpdate); }; }, [app, el, onInteractionEvent, onPointerMove, onFrameUpdate]); }; //# sourceMappingURL=picker.js.map