@playcanvas/react
Version:
A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.
234 lines • 8.69 kB
JavaScript
"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