UNPKG

@playcanvas/react

Version:

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

234 lines 8.69 kB
"use client"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { GltfContext } from "../context.js"; import { ActionType } from "../types.js"; import { PathMatcher } from "../utils/path-matcher.js"; import { RuleProcessor } from "./RuleProcessor.js"; import { useParent } from "../../hooks/use-parent.js"; /** * Root component for GLTF scene modification system * * Provides: * - Lazy instantiation of GLTF assets * - Hierarchy cache for entity lookups * - Rule collection and conflict resolution * - Batched modification application * - Optional rendering of GLTF visuals * * @example * ```tsx * const { asset } = useModel('model.glb'); * * return ( * <Gltf asset={asset} key={asset.id}> * <Modify.Node path="head.*[light]"> * <Modify.Component type="light" remove /> * </Modify.Node> * </Gltf> * ); * ``` * * @example * ```tsx * // Don't render visuals, only process modifications * <Gltf asset={asset} key={asset.id} render={false}> * <Modify.Node path="**[light]"> * <Modify.Component type="light" remove /> * </Modify.Node> * </Gltf> * ``` */ export const Gltf = ({ asset, render = true, children }) => { const parent = useParent(); const [rootEntity, setRootEntity] = useState(null); const [hierarchyCache, setHierarchyCache] = useState(new Map()); const rulesRef = useRef(new Map()); const [mergedRules, setMergedRules] = useState(new Map()); const pathMatcher = useMemo(() => new PathMatcher(), []); const [rulesVersion, setRulesVersion] = useState(0); // Check if we need to instantiate (render is true OR has Modify.Node children) const shouldInstantiate = useMemo(() => { // Always instantiate if render is true if (render) return true; // Otherwise, check for Modify.Node children if (!children) return false; const childArray = React.Children.toArray(children); return childArray.some((child) => { if (React.isValidElement(child)) { const type = child.type; const displayName = type?.displayName || type?.name; return displayName === 'ModifyNode'; } return false; }); }, [render, children]); // Instantiate asset and build hierarchy cache useEffect(() => { if (!asset || !asset.resource || !shouldInstantiate || !parent) { return; } // Instantiate the render entity if (!asset.resource || // We should use GLBContainerResource instead of any, but its not exported from playcanvas // eslint-disable-next-line @typescript-eslint/no-explicit-any typeof asset.resource.instantiateRenderEntity !== 'function') { console.error('Asset resource does not have instantiateRenderEntity method'); return; } const entity = asset.resource.instantiateRenderEntity(); if (!entity) { console.error('Failed to instantiate GLTF asset'); return; } // Build hierarchy cache const cache = new Map(); buildHierarchyCache(entity, '', cache); setRootEntity(entity); setHierarchyCache(cache); // Cleanup return () => { entity.destroy(); setRootEntity(null); setHierarchyCache(new Map()); }; }, [asset, shouldInstantiate, parent]); // Process rules and resolve conflicts const processRules = useCallback(() => { if (!rootEntity || hierarchyCache.size === 0) { setMergedRules(new Map()); return; } const merged = new Map(); // For each entity in the hierarchy for (const [guid, metadata] of hierarchyCache.entries()) { const matchingRules = []; // Find all rules that match this entity for (const rule of rulesRef.current.values()) { if (pathMatcher.matches(rule.path, metadata)) { matchingRules.push(rule); } } // If no rules match, skip if (matchingRules.length === 0) { continue; } // Merge and resolve conflicts const mergedRule = mergeRules(guid, matchingRules); merged.set(guid, mergedRule); } setMergedRules(merged); }, [rootEntity, hierarchyCache, pathMatcher]); // Rule registration callbacks const registerRule = useCallback((rule) => { rulesRef.current.set(rule.id, rule); // "Poke" the component to re-process rules setRulesVersion(v => v + 1); }, []); const unregisterRule = useCallback((ruleId) => { rulesRef.current.delete(ruleId); // "Poke" the component to re-process rules setRulesVersion(v => v + 1); }, []); // Re-process rules when dependencies change useEffect(() => { // This effect now runs ONLY when the cache is ready // or when the rules have *actually* changed. processRules(); }, [processRules, hierarchyCache, rulesVersion]); // Add root entity to parent scene (merged from Gltf component) useEffect(() => { if (!rootEntity || !parent || !render) { return; } parent.addChild(rootEntity); return () => { // Only remove if still a child (Gltf's cleanup will destroy it) if (rootEntity.parent === parent) { parent.removeChild(rootEntity); } }; }, [rootEntity, parent, render]); // Context value const contextValue = useMemo(() => ({ hierarchyCache, rootEntity, pathMatcher, registerRule, unregisterRule, }), [hierarchyCache, rootEntity, pathMatcher, registerRule, unregisterRule]); // Don't render anything if not instantiated if (!rootEntity) { return null; } return (_jsxs(GltfContext.Provider, { value: contextValue, children: [children, Array.from(mergedRules.entries()).map(([guid, rule]) => { const metadata = hierarchyCache.get(guid); if (!metadata) return null; return (_jsx(RuleProcessor, { entity: metadata.entity, rule: rule, originalChildGUIDs: metadata.originalChildGUIDs ?? [] }, guid)); })] })); }; /** * Builds a hierarchy cache by traversing the entity tree */ function buildHierarchyCache(entity, parentPath, cache) { const path = parentPath ? `${parentPath}.${entity.name}` : entity.name; const guid = entity.getGuid(); const originalChildGUIDs = entity.children.map((child) => child.getGuid()); cache.set(guid, { entity, path, guid, name: entity.name, originalChildGUIDs }); // Recursively process children for (const child of entity.children) { buildHierarchyCache(child, path, cache); } } /** * Merges multiple rules for the same entity and resolves conflicts */ function mergeRules(entityGuid, rules) { const merged = { entityGuid, clearChildren: false, componentActions: new Map(), addChildren: [] }; // Sort rules by specificity (highest first) const sortedRules = [...rules].sort((a, b) => b.specificity - a.specificity); // Process each rule for (const rule of sortedRules) { for (const action of rule.actions) { switch (action.type) { case ActionType.CLEAR_CHILDREN: // First rule with clearChildren wins if (!merged.clearChildren) { merged.clearChildren = true; } break; case ActionType.ADD_CHILDREN: { // Collect all additions const addAction = action; merged.addChildren.push(...addAction.children); break; } case ActionType.MODIFY_COMPONENT: { // For component actions, highest specificity wins per component type const componentAction = action; if (!merged.componentActions.has(componentAction.componentType)) { merged.componentActions.set(componentAction.componentType, action); } break; } } } } return merged; } Gltf.displayName = 'Gltf'; //# sourceMappingURL=Gltf.js.map