Skip to content

Commit e800b2e

Browse files
marklundinCopilot
andauthored
Improved GLTF API (#282)
* feat(gltf-scene): introduce GLTF Scene Modification API and related components - Added a new GLTF Scene Modification API, including components like GltfScene, Modify, and hooks for entity management. - Implemented context for managing GLTF scene state and hierarchy. - Introduced examples demonstrating the usage of the new API for modifying GLTF assets. - Updated existing components (Camera, Light, Render) to export their definitions for better integration with the new API. - Added type definitions for improved TypeScript support. This commit enhances the library's capabilities for declarative modifications of GLTF assets, providing a more flexible and powerful way to manage 3D scenes. * feat: add new <Gltf/> API for enhanced 3D scene management * refactor(gltf): restructure GLTF API and introduce new components - Moved GLTF scene management to a dedicated API, enhancing modularity and usability. - Introduced new components: Gltf, Modify, and associated hooks for improved entity manipulation. - Updated type definitions and context management for better integration and TypeScript support. - Added examples demonstrating the new API's capabilities for modifying GLTF assets. - Removed deprecated components and streamlined the overall structure for clarity. This commit significantly enhances the library's functionality for declarative modifications of GLTF assets, providing a more robust framework for 3D scene management. * Improve tests * fix(gltf): enhance Gltf component and integration tests - Updated the Gltf component to include a check for the parent entity in the useEffect hook, improving asset instantiation logic. - Refactored integration tests to improve clarity and coverage, including modifications to existing component props and handling of edge cases. - Adjusted test assertions to ensure proper state before and after modifications, enhancing reliability of test outcomes. - Improved the structure of test cases for better readability and maintainability. * refactor(gltf): enhance integration tests with new test components - Introduced a new file containing test component fixtures to streamline integration tests for the Gltf component. - Replaced direct Gltf usage in tests with specific test components to improve clarity and maintainability. - Updated various test cases to utilize the new components, demonstrating different GLTF modification patterns. - Enhanced the overall structure of integration tests for better readability and organization. * add warnings tests * feat(gltf): add warnings for adding existing components to entities - Enhanced the RuleProcessor component to check for existing components on entities before adding new ones. - Introduced a warning mechanism to inform users when attempting to add a component that already exists, guiding them to use modification actions instead. - Updated tests to reflect changes in component handling and ensure proper functionality. * Fixed entity cast * updated changeset * Update packages/lib/src/gltf/examples.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor(gltf): update rule merging logic to use MODIFY_COMPONENT action - Changed instances of REMOVE_COMPONENT to MODIFY_COMPONENT in rule merging tests to reflect updated action handling. - Enhanced test cases to ensure proper functionality of component modifications, including the addition of props for removal. - Improved clarity and maintainability of tests by aligning with the new action structure. * refactor(tests): enhance rule merging tests for clarity and accuracy - Updated assertions in the rule merging test to explicitly check the winning rule's ID, action type, and properties. - Improved test readability by assigning the winning action to a variable for clearer context. - Ensured that the tests accurately reflect the expected behavior of the merging logic with the MODIFY_COMPONENT action. * Update packages/lib/src/gltf/components/Gltf.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor(gltf): optimize child clearing logic in RuleProcessor component - Improved efficiency of child clearing by using a Map for O(1) lookups of current children by GUID. - Updated the logic to find original children, enhancing performance and clarity of the code. - Ensured that the functionality remains intact while streamlining the process of managing entity children. * fix(gltf): correct import extension and add useEntity tests - Updated the import of useParent from '.ts' to '.tsx' for consistency. - Introduced comprehensive tests for the useEntity hook, covering various path matching scenarios, wildcard usage, and predicate functions. - Enhanced test coverage to ensure accurate entity retrieval and handling of edge cases. * fix(gltf): resolve entity component duplication warnings - Implemented logic in the RuleProcessor to prevent adding duplicate components to entities. - Added warning messages to inform users when attempting to add an existing component, promoting the use of modification actions. - Updated tests to verify the new warning functionality and ensure correct handling of component additions. * test(gltf): add comprehensive tests for functional prop updates - Introduced multiple test cases to validate the behavior of functional updates for light and render components. - Ensured correct handling of various scenarios, including undefined values, boolean toggles, and zero values. - Enhanced test coverage for the Modify component to verify the integration of functional updates with direct prop modifications. * Update .changeset/stale-pandas-fetch.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(gltf): enhance useEntity hook with PathMatcher for improved entity retrieval - Integrated PathMatcher into the useEntity hook to support consistent path matching, including component filters and wildcards. - Updated the logic for finding entities by pattern, allowing for more flexible and efficient entity searches. - Added comprehensive tests to validate the new functionality, ensuring correct behavior with various path patterns and component filters. * refactor(gltf): improve useEntity hook for enhanced entity retrieval - Refactored the useEntity hook to streamline entity matching logic, ensuring consistent handling of string paths and predicate functions. - Updated return logic to handle wildcards and predicates more effectively, returning arrays or null as appropriate. - Enhanced traversal methods in helper functions to build relative paths correctly, improving the accuracy of entity searches. * Update packages/lib/src/gltf/components/Gltf.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * linting fix * refactor(gltf): remove unused exports from index files - Removed unused exports PathMatcher and defaultPathMatcher from gltf/index.ts to streamline the module. - Cleaned up index.ts by eliminating the unused useEntity export, improving code clarity and maintainability. * Update packages/lib/src/gltf/components/RuleProcessor.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/lib/src/Application.test.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/lib/src/gltf/hooks/use-entity.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore(gltf): remove COVERAGE_ANALYSIS.md file - Deleted the COVERAGE_ANALYSIS.md file as it is no longer needed for tracking test coverage analysis for the GLTF module. - This cleanup helps streamline the documentation and focuses on more relevant resources. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c623176 commit e800b2e

28 files changed

+6161
-3
lines changed

.changeset/stale-pandas-fetch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@playcanvas/react": minor
3+
---
4+
5+
Introduced a new declarative GLTF modification API. Users can now use the `<Gltf>` and `Modify` components to add, remove, and modify components (like `light`, `render`, `camera`) on entities within a loaded GLB hierarchy.

packages/lib/src/components/Camera.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@ const componentDefinition = createComponentDefinition<CameraProps, CameraCompone
3333
() => new Entity("mock-camera", getStaticNullApplication()).addComponent('camera') as CameraComponent,
3434
(component) => (component as CameraComponent).system.destroy(),
3535
{ apiName: "CameraComponent" }
36-
)
36+
)
37+
38+
export { componentDefinition as cameraComponentDefinition };

packages/lib/src/components/Light.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ componentDefinition.schema = {
4747
errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "directional", "omni", "spot".`,
4848
default: "directional"
4949
}
50-
} as Schema<LightProps, LightComponent>
50+
} as Schema<LightProps, LightComponent>
51+
52+
export { componentDefinition as lightComponentDefinition };

packages/lib/src/components/Render.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,5 @@ componentDefinition.schema = {
9595
}
9696
}
9797

98+
export { componentDefinition as renderComponentDefinition };
9899
export default Render;
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
"use client";
2+
3+
import React, { useState, useEffect, useMemo, useCallback, ReactNode, useRef } from 'react';
4+
import { Asset, Entity } from 'playcanvas';
5+
import { GltfContext } from '../context.tsx';
6+
import { Rule, MergedRule, ActionType, ModifyComponentAction } from '../types.ts';
7+
import { EntityMetadata, PathMatcher } from '../utils/path-matcher.ts';
8+
import { RuleProcessor } from './RuleProcessor.tsx';
9+
import { useParent } from '../../hooks/use-parent.tsx';
10+
11+
export interface GltfProps {
12+
/**
13+
* The GLTF asset loaded via useModel
14+
*/
15+
asset: Asset;
16+
17+
/**
18+
* Whether to render the GLTF scene visuals
19+
* @default true
20+
*/
21+
render?: boolean;
22+
23+
/**
24+
* Children should contain <Modify.Node> components
25+
*/
26+
children?: ReactNode;
27+
}
28+
29+
/**
30+
* Root component for GLTF scene modification system
31+
*
32+
* Provides:
33+
* - Lazy instantiation of GLTF assets
34+
* - Hierarchy cache for entity lookups
35+
* - Rule collection and conflict resolution
36+
* - Batched modification application
37+
* - Optional rendering of GLTF visuals
38+
*
39+
* @example
40+
* ```tsx
41+
* const { asset } = useModel('model.glb');
42+
*
43+
* return (
44+
* <Gltf asset={asset} key={asset.id}>
45+
* <Modify.Node path="head.*[light]">
46+
* <Modify.Component type="light" remove />
47+
* </Modify.Node>
48+
* </Gltf>
49+
* );
50+
* ```
51+
*
52+
* @example
53+
* ```tsx
54+
* // Don't render visuals, only process modifications
55+
* <Gltf asset={asset} key={asset.id} render={false}>
56+
* <Modify.Node path="**[light]">
57+
* <Modify.Component type="light" remove />
58+
* </Modify.Node>
59+
* </Gltf>
60+
* ```
61+
*/
62+
export const Gltf: React.FC<GltfProps> = ({ asset, render = true, children }) => {
63+
const parent = useParent();
64+
const [rootEntity, setRootEntity] = useState<Entity | null>(null);
65+
const [hierarchyCache, setHierarchyCache] = useState<Map<string, EntityMetadata>>(new Map());
66+
const rulesRef = useRef<Map<string, Rule>>(new Map());
67+
const [mergedRules, setMergedRules] = useState<Map<string, MergedRule>>(new Map());
68+
const pathMatcher = useMemo(() => new PathMatcher(), []);
69+
const [rulesVersion, setRulesVersion] = useState(0);
70+
71+
// Check if we need to instantiate (render is true OR has Modify.Node children)
72+
const shouldInstantiate = useMemo(() => {
73+
// Always instantiate if render is true
74+
if (render) return true;
75+
76+
// Otherwise, check for Modify.Node children
77+
if (!children) return false;
78+
79+
const childArray = React.Children.toArray(children);
80+
return childArray.some((child) => {
81+
if (React.isValidElement(child)) {
82+
const type = child.type as { displayName?: string; name?: string };
83+
const displayName = type?.displayName || type?.name;
84+
return displayName === 'ModifyNode';
85+
}
86+
return false;
87+
});
88+
}, [render, children]);
89+
90+
// Instantiate asset and build hierarchy cache
91+
useEffect(() => {
92+
if (!asset || !asset.resource || !shouldInstantiate || !parent) {
93+
return;
94+
}
95+
96+
// Instantiate the render entity
97+
if (
98+
!asset.resource ||
99+
// We should use GLBContainerResource instead of any, but its not exported from playcanvas
100+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
101+
typeof (asset.resource as any).instantiateRenderEntity !== 'function'
102+
) {
103+
console.error('Asset resource does not have instantiateRenderEntity method');
104+
return;
105+
}
106+
const entity = (asset.resource as { instantiateRenderEntity: () => Entity }).instantiateRenderEntity();
107+
108+
if (!entity) {
109+
console.error('Failed to instantiate GLTF asset');
110+
return;
111+
}
112+
113+
// Build hierarchy cache
114+
const cache = new Map<string, EntityMetadata>();
115+
buildHierarchyCache(entity, '', cache);
116+
117+
setRootEntity(entity);
118+
setHierarchyCache(cache);
119+
120+
// Cleanup
121+
return () => {
122+
entity.destroy();
123+
setRootEntity(null);
124+
setHierarchyCache(new Map());
125+
};
126+
}, [asset, shouldInstantiate, parent]);
127+
128+
// Process rules and resolve conflicts
129+
const processRules = useCallback(() => {
130+
if (!rootEntity || hierarchyCache.size === 0) {
131+
setMergedRules(new Map());
132+
return;
133+
}
134+
135+
const merged = new Map<string, MergedRule>();
136+
137+
// For each entity in the hierarchy
138+
for (const [guid, metadata] of hierarchyCache.entries()) {
139+
const matchingRules: Rule[] = [];
140+
141+
// Find all rules that match this entity
142+
for (const rule of rulesRef.current.values()) {
143+
if (pathMatcher.matches(rule.path, metadata)) {
144+
matchingRules.push(rule);
145+
}
146+
}
147+
148+
// If no rules match, skip
149+
if (matchingRules.length === 0) {
150+
continue;
151+
}
152+
153+
// Merge and resolve conflicts
154+
const mergedRule = mergeRules(guid, matchingRules);
155+
merged.set(guid, mergedRule);
156+
}
157+
158+
setMergedRules(merged);
159+
}, [rootEntity, hierarchyCache, pathMatcher]);
160+
161+
// Rule registration callbacks
162+
const registerRule = useCallback((rule: Rule) => {
163+
rulesRef.current.set(rule.id, rule);
164+
// "Poke" the component to re-process rules
165+
setRulesVersion(v => v + 1);
166+
}, []);
167+
168+
const unregisterRule = useCallback((ruleId: string) => {
169+
rulesRef.current.delete(ruleId);
170+
// "Poke" the component to re-process rules
171+
setRulesVersion(v => v + 1);
172+
}, []);
173+
174+
// Re-process rules when dependencies change
175+
useEffect(() => {
176+
// This effect now runs ONLY when the cache is ready
177+
// or when the rules have *actually* changed.
178+
processRules();
179+
}, [processRules, hierarchyCache, rulesVersion]);
180+
181+
// Add root entity to parent scene (merged from Gltf component)
182+
useEffect(() => {
183+
if (!rootEntity || !parent || !render) {
184+
return;
185+
}
186+
187+
parent.addChild(rootEntity);
188+
189+
return () => {
190+
// Only remove if still a child (Gltf's cleanup will destroy it)
191+
if (rootEntity.parent === parent) {
192+
parent.removeChild(rootEntity);
193+
}
194+
};
195+
}, [rootEntity, parent, render]);
196+
197+
// Context value
198+
const contextValue = useMemo(() => ({
199+
hierarchyCache,
200+
rootEntity,
201+
pathMatcher,
202+
registerRule,
203+
unregisterRule,
204+
}), [hierarchyCache, rootEntity, pathMatcher, registerRule, unregisterRule]);
205+
206+
// Don't render anything if not instantiated
207+
if (!rootEntity) {
208+
return null;
209+
}
210+
211+
return (
212+
<GltfContext.Provider value={contextValue}>
213+
{/* Render children (includes Gltf and Modify.Node components) */}
214+
{children}
215+
216+
{/* Render rule processors */}
217+
{Array.from(mergedRules.entries()).map(([guid, rule]) => {
218+
const metadata = hierarchyCache.get(guid);
219+
if (!metadata) return null;
220+
221+
return (
222+
<RuleProcessor
223+
key={guid}
224+
entity={metadata.entity}
225+
rule={rule}
226+
originalChildGUIDs={metadata.originalChildGUIDs ?? []}
227+
/>
228+
);
229+
})}
230+
</GltfContext.Provider>
231+
);
232+
};
233+
234+
/**
235+
* Builds a hierarchy cache by traversing the entity tree
236+
*/
237+
function buildHierarchyCache(
238+
entity: Entity,
239+
parentPath: string,
240+
cache: Map<string, EntityMetadata>
241+
): void {
242+
const path = parentPath ? `${parentPath}.${entity.name}` : entity.name;
243+
const guid = entity.getGuid();
244+
245+
const originalChildGUIDs = entity.children.map((child) => (child as Entity).getGuid());
246+
247+
cache.set(guid, {
248+
entity,
249+
path,
250+
guid,
251+
name: entity.name,
252+
originalChildGUIDs
253+
});
254+
255+
// Recursively process children
256+
for (const child of entity.children) {
257+
buildHierarchyCache(child as Entity, path, cache);
258+
}
259+
}
260+
261+
/**
262+
* Merges multiple rules for the same entity and resolves conflicts
263+
*/
264+
function mergeRules(entityGuid: string, rules: Rule[]): MergedRule {
265+
const merged: MergedRule = {
266+
entityGuid,
267+
clearChildren: false,
268+
componentActions: new Map(),
269+
addChildren: []
270+
};
271+
272+
// Sort rules by specificity (highest first)
273+
const sortedRules = [...rules].sort((a, b) => b.specificity - a.specificity);
274+
275+
// Process each rule
276+
for (const rule of sortedRules) {
277+
for (const action of rule.actions) {
278+
switch (action.type) {
279+
case ActionType.CLEAR_CHILDREN:
280+
// First rule with clearChildren wins
281+
if (!merged.clearChildren) {
282+
merged.clearChildren = true;
283+
}
284+
break;
285+
286+
case ActionType.ADD_CHILDREN: {
287+
// Collect all additions
288+
const addAction = action as { children: ReactNode[] };
289+
merged.addChildren.push(...addAction.children);
290+
break;
291+
}
292+
293+
case ActionType.MODIFY_COMPONENT: {
294+
// For component actions, highest specificity wins per component type
295+
const componentAction = action as ModifyComponentAction;
296+
if (!merged.componentActions.has(componentAction.componentType)) {
297+
merged.componentActions.set(componentAction.componentType, action);
298+
}
299+
break;
300+
}
301+
}
302+
}
303+
}
304+
305+
return merged;
306+
}
307+
308+
Gltf.displayName = 'Gltf';
309+

0 commit comments

Comments
 (0)