-
-
Notifications
You must be signed in to change notification settings - Fork 395
feat(loader): support path-based cross-prefab entity/component refs #2927
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9337c4f
738f088
4ae031b
06f8191
2234344
a1c8e58
c11e3f1
6df1a04
fa04739
3027c82
0b2067a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,19 @@ | ||
| import { expect, beforeAll, afterAll, describe, it } from "vitest"; | ||
| import { WebGLEngine } from "@galacean/engine-rhi-webgl"; | ||
| import type { IHierarchyFile } from "@galacean/engine-loader"; | ||
| import { Loader, MeshRenderer, Script } from "@galacean/engine-core"; | ||
| import { PrefabParser } from "../../../packages/loader/src/prefab/PrefabParser"; | ||
|
|
||
| let engine: WebGLEngine; | ||
|
|
||
| class DiceScript extends Script { | ||
| skinMesh: MeshRenderer = null; | ||
| numMesh: MeshRenderer = null; | ||
| } | ||
|
|
||
| Loader.registerClass("MeshRenderer", MeshRenderer); | ||
| Loader.registerClass("DiceScript", DiceScript); | ||
|
|
||
| beforeAll(async () => { | ||
| const canvas = document.createElement("canvas"); | ||
| canvas.width = 256; | ||
|
|
@@ -52,3 +61,149 @@ describe("PrefabResource refCount", () => { | |
| expect(prefab.refCount).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| describe("Cross-prefab IComponentRef (path-based)", () => { | ||
| it("should resolve component ref inside nested prefab instance via entityPath", async () => { | ||
| // 1. nested prefab (dice.prefab): single root entity with MeshRenderer | ||
| const nestedPrefabData: IHierarchyFile = { | ||
| entities: [ | ||
| { | ||
| id: "0", | ||
| name: "diceRoot", | ||
| components: [{ id: "mesh-comp", class: "MeshRenderer" }], | ||
| children: [] | ||
| } | ||
| ] | ||
| }; | ||
| const nestedPrefab = await PrefabParser.parse(engine, "dice.prefab", nestedPrefabData); | ||
| // @ts-ignore — register to resourceManager so getResourceByRef can find it | ||
| engine.resourceManager._objectPool["dice.prefab"] = nestedPrefab; | ||
|
|
||
|
Comment on lines
+78
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard If an assertion throws before the tail cleanup lines, Suggested pattern const nestedPrefab = await PrefabParser.parse(engine, "dice.prefab", nestedPrefabData);
// `@ts-ignore`
engine.resourceManager._objectPool["dice.prefab"] = nestedPrefab;
-
-const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData);
-const root = outerPrefab.instantiate();
-const script = root.getComponent(DiceScript);
-
-expect(script.skinMesh).not.toBeNull();
-...
-root.destroy();
-// `@ts-ignore`
-delete engine.resourceManager._objectPool["dice.prefab"];
+try {
+ const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData);
+ const root = outerPrefab.instantiate();
+ const script = root.getComponent(DiceScript);
+
+ expect(script.skinMesh).not.toBeNull();
+ ...
+ root.destroy();
+} finally {
+ // `@ts-ignore`
+ delete engine.resourceManager._objectPool["dice.prefab"];
+}Also applies to: 123-126, 139-142, 204-207 🤖 Prompt for AI Agents |
||
| // 2. outer prefab (DiceNode.prefab) | ||
| // resolved tree: DiceNode(root=[0]) -> [0]dice(nested) | ||
| // skinMesh crosses nested prefab boundary via entityPath | ||
| const outerPrefabData: IHierarchyFile = { | ||
| entities: [ | ||
| { | ||
| id: "root", | ||
| name: "DiceNode", | ||
| components: [ | ||
| { | ||
| id: "script-comp", | ||
| class: "DiceScript", | ||
| props: { | ||
| skinMesh: { entityPath: [0, 0], componentType: "MeshRenderer", componentIndex: 0 } | ||
| } | ||
| } | ||
| ], | ||
| children: ["dice-inst"] | ||
| }, | ||
| { | ||
| id: "dice-inst", | ||
| name: "dice", | ||
| parent: "root", | ||
| components: [], | ||
| assetUrl: "dice.prefab", | ||
| modifications: [], | ||
| removedEntities: [], | ||
| removedComponents: [] | ||
| } | ||
| ] | ||
| }; | ||
|
|
||
| const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData); | ||
| const root = outerPrefab.instantiate(); | ||
| const script = root.getComponent(DiceScript); | ||
|
|
||
| // skinMesh should point to the MeshRenderer inside nested prefab instance | ||
| expect(script.skinMesh).not.toBeNull(); | ||
| expect(script.skinMesh).toBeInstanceOf(MeshRenderer); | ||
| expect(script.skinMesh.entity.name).toBe("dice"); | ||
|
|
||
| root.destroy(); | ||
| // @ts-ignore | ||
| delete engine.resourceManager._objectPool["dice.prefab"]; | ||
| }); | ||
|
|
||
| it("should resolve both local and cross-prefab refs, and survive clone independently", async () => { | ||
| const nestedPrefabData: IHierarchyFile = { | ||
| entities: [ | ||
| { | ||
| id: "0", | ||
| name: "diceRoot", | ||
| components: [{ id: "mesh-comp", class: "MeshRenderer" }], | ||
| children: [] | ||
| } | ||
| ] | ||
| }; | ||
| const nestedPrefab = await PrefabParser.parse(engine, "dice.prefab", nestedPrefabData); | ||
| // @ts-ignore | ||
| engine.resourceManager._objectPool["dice.prefab"] = nestedPrefab; | ||
|
|
||
| // resolved tree: DiceNode(root=[0]) -> [0]numCube, [1]dice(nested) | ||
| const outerPrefabData: IHierarchyFile = { | ||
| entities: [ | ||
| { | ||
| id: "root", | ||
| name: "DiceNode", | ||
| components: [ | ||
| { | ||
| id: "script-comp", | ||
| class: "DiceScript", | ||
| props: { | ||
| numMesh: { entityPath: [0, 0], componentType: "MeshRenderer", componentIndex: 0 }, | ||
| skinMesh: { entityPath: [0, 1], componentType: "MeshRenderer", componentIndex: 0 } | ||
| } | ||
| } | ||
| ], | ||
| children: ["num-cube", "dice-inst"] | ||
| }, | ||
| { | ||
| id: "num-cube", | ||
| name: "numCube", | ||
| parent: "root", | ||
| components: [{ id: "num-mesh-comp", class: "MeshRenderer" }] | ||
| }, | ||
| { | ||
| id: "dice-inst", | ||
| name: "dice", | ||
| parent: "root", | ||
| components: [], | ||
| assetUrl: "dice.prefab", | ||
| modifications: [], | ||
| removedEntities: [], | ||
| removedComponents: [] | ||
| } | ||
| ] | ||
| }; | ||
|
|
||
| const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData); | ||
|
|
||
| const instance1 = outerPrefab.instantiate(); | ||
| const instance2 = outerPrefab.instantiate(); | ||
|
|
||
| const script1 = instance1.getComponent(DiceScript); | ||
| const script2 = instance2.getComponent(DiceScript); | ||
|
|
||
| // instance1: numMesh -> numCube's MeshRenderer, skinMesh -> dice's MeshRenderer | ||
| const numMesh1 = instance1.children[0].getComponent(MeshRenderer); | ||
| const skinMesh1 = instance1.children[1].getComponent(MeshRenderer); | ||
| expect(script1.numMesh).toBe(numMesh1); | ||
| expect(script1.skinMesh).toBe(skinMesh1); | ||
|
|
||
| // instance2: independent refs | ||
| const numMesh2 = instance2.children[0].getComponent(MeshRenderer); | ||
| const skinMesh2 = instance2.children[1].getComponent(MeshRenderer); | ||
| expect(script2.numMesh).toBe(numMesh2); | ||
| expect(script2.skinMesh).toBe(skinMesh2); | ||
|
|
||
| // refs across instances are independent | ||
| expect(script1.numMesh).not.toBe(script2.numMesh); | ||
| expect(script1.skinMesh).not.toBe(script2.skinMesh); | ||
|
|
||
| instance1.destroy(); | ||
| instance2.destroy(); | ||
| // @ts-ignore | ||
| delete engine.resourceManager._objectPool["dice.prefab"]; | ||
| }); | ||
| }); | ||
|
Comment on lines
+65
to
+209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add regression tests for the two known parser edge cases before merge. This suite is good for nested/cross-prefab refs, but it still misses the two explicitly raised risks:
Please add tests that lock in intended semantics (especially pre-removal vs post-removal interpretation). Suggested test skeleton+it("should resolve top-level entityPath deterministically with multiple async prefab roots", async () => {
+ // Arrange: two top-level prefab instances with controllable async completion order.
+ // Assert: entityPath[0] binds by serialized order (or documented contract), not completion timing.
+});
+
+it("should define componentIndex/entityPath semantics with removedEntities/removedComponents", async () => {
+ // Arrange: target component/entity preceded by removable siblings.
+ // Assert: binding semantics stay consistent with documented behavior.
+});🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Harden
_resolveEntityByPathindex validation and null contract.Line 191 only checks upper bounds. Negative/non-integer indices can slip through, and Line 198 can return
undefined(notnull) on malformed input.Suggested fix
private _resolveEntityByPath(entityPath: number[]): Entity | null { const { rootIds, entityMap } = this._context; - if (!entityPath.length || entityPath[0] >= rootIds.length) return null; - let entity = entityMap.get(rootIds[entityPath[0]]); + const isValidIndex = (index: number, length: number) => + Number.isInteger(index) && index >= 0 && index < length; + + if (!entityPath.length || !isValidIndex(entityPath[0], rootIds.length)) return null; + + let entity = entityMap.get(rootIds[entityPath[0]]); + if (!entity) return null; + for (let i = 1; i < entityPath.length; i++) { - if (!entity || entityPath[i] >= entity.children.length) return null; + if (!isValidIndex(entityPath[i], entity.children.length)) return null; entity = entity.children[entityPath[i]]; + if (!entity) return null; } - return entity; + return entity ?? null; }🤖 Prompt for AI Agents