diff --git a/api/physics.js b/api/physics.js index 7b61703d..aaa91eef 100644 --- a/api/physics.js +++ b/api/physics.js @@ -437,19 +437,41 @@ export const flockPhysics = { name.includes("__") ? name.split("__")[0] : name.split("_")[0]; const groupName = getGroupRoot(meshName); + const getAllGuiControls = () => { + const root = + flock.scene?.UITexture?._rootContainer ?? + flock.scene?.UITexture?.rootContainer; + if (!root) return []; + if (typeof root.getDescendants === "function") { + return root.getDescendants(false); + } + const all = []; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop(); + const children = node?._children ?? node?.children ?? []; + for (const child of children) { + all.push(child); + stack.push(child); + } + } + return all; + }; if (!flock.scene) { if (!flock.pendingTriggers.has(groupName)) flock.pendingTriggers.set(groupName, []); - flock.pendingTriggers.get(groupName).push({ trigger, callback, mode }); + flock.pendingTriggers + .get(groupName) + .push({ meshName, trigger, callback, mode, applyToGroup }); return; } if (applyToGroup) { let matchingButtons = []; if (flock.scene.UITexture) { - matchingButtons = flock.scene.UITexture._rootContainer._children.filter( - (control) => control.name && getGroupRoot(control.name) === groupName, + matchingButtons = getAllGuiControls().filter( + (control) => control?.name && getGroupRoot(control.name) === groupName, ); } const matching = flock.scene.meshes.filter( @@ -478,15 +500,15 @@ export const flockPhysics = { } if (!flock.pendingTriggers.has(groupName)) flock.pendingTriggers.set(groupName, []); - flock.pendingTriggers.get(groupName).push({ trigger, callback, mode }); + flock.pendingTriggers + .get(groupName) + .push({ meshName, trigger, callback, mode, applyToGroup }); return; } let guiButton = null; if (flock.scene.UITexture) { - guiButton = flock.scene.UITexture._rootContainer._children.find( - (c) => c.name === meshName, - ); + guiButton = flock.scene.UITexture.getControlByName?.(meshName) ?? null; } const tryNow = @@ -497,7 +519,9 @@ export const flockPhysics = { if (!tryNow) { if (!flock.pendingTriggers.has(groupName)) flock.pendingTriggers.set(groupName, []); - flock.pendingTriggers.get(groupName).push({ trigger, callback, mode }); + flock.pendingTriggers + .get(groupName) + .push({ meshName, trigger, callback, mode, applyToGroup }); return; } @@ -606,7 +630,71 @@ export const flockPhysics = { }); }); }, - onIntersect(meshName, otherMeshName, { trigger, callback }) { + onIntersect( + meshName, + otherMeshName, + { trigger, callback, applyToGroupOther = false } = {}, + ) { + const getGroupRoot = (name) => + name.includes("__") ? name.split("__")[0] : name.split("_")[0]; + const resolveCanonicalGroupName = (rawName) => { + const scene = flock.scene; + const exact = scene?.getMeshByName?.(rawName); + if (exact?.name) return getGroupRoot(exact.name); + + let normalized = rawName.includes("__") ? rawName.split("__")[0] : rawName; + normalized = normalized.replace(/[^a-zA-Z0-9._-]/g, ""); + + if (normalized && normalized !== rawName) { + if ( + scene?.getMeshByName?.(normalized) || + flock.modelReadyPromises.has(normalized) + ) { + return getGroupRoot(normalized); + } + } + + return getGroupRoot(rawName); + }; + + if (applyToGroupOther) { + const groupName = resolveCanonicalGroupName(otherMeshName); + + if (!flock.pendingIntersections.has(groupName)) { + flock.pendingIntersections.set(groupName, []); + } + + const pendingEntry = { + meshName, + trigger, + callback, + registeredOthers: new Set(), + }; + flock.pendingIntersections.get(groupName).push(pendingEntry); + + const registerForOther = (name) => { + if (name === meshName || pendingEntry.registeredOthers.has(name)) { + return Promise.resolve(); + } + pendingEntry.registeredOthers.add(name); + return flock.onIntersect(meshName, name, { + trigger, + callback, + applyToGroupOther: false, + }); + }; + + if (flock.scene) { + const matching = flock.scene.meshes.filter( + (m) => getGroupRoot(m.name) === groupName, + ); + const matchingNames = [...new Set(matching.map((m) => m.name))]; + return Promise.all(matchingNames.map((name) => registerForOther(name))); + } + + return; + } + return new Promise((resolve) => { flock.whenModelReady(meshName, async function (mesh) { if (!mesh) { diff --git a/flock.js b/flock.js index 53a48941..60de7c17 100644 --- a/flock.js +++ b/flock.js @@ -119,6 +119,7 @@ export const flock = { modelReadyPromises: new Map(), pendingMeshCreations: 0, pendingTriggers: new Map(), + pendingIntersections: new Map(), _nameRegistry: new Map(), _animationFileCache: {}, getModelDisplayName, @@ -1833,6 +1834,7 @@ export const flock = { flock.geometryCache = {}; flock.materialCache = {}; flock.pendingTriggers = new Map(); + flock.pendingIntersections = new Map(); flock._nameRegistry = new Map(); flock._animationFileCache = {}; flock.ground = null; @@ -1878,6 +1880,7 @@ export const flock = { flock.originalModelTransformations = {}; flock.geometryCache = {}; flock.pendingTriggers = new Map(); + flock.pendingIntersections = new Map(); flock._nameRegistry = new Map(); flock._animationFileCache = {}; flock.materialCache = {}; @@ -2498,32 +2501,72 @@ export const flock = { const getGroupRoot = (name) => name.includes("__") ? name.split("__")[0] : name.split("_")[0]; - if (!flock.pendingTriggers.has(groupName)) return; - - const triggers = flock.pendingTriggers.get(groupName); - - for (const { trigger, callback, mode, applyToGroup } of triggers) { - if (applyToGroup) { - // 🔁 Reapply trigger across all matching meshes - const matching = flock.scene.meshes.filter( - (m) => getGroupRoot(m.name) === groupName, - ); - for (const m of matching) { - flock.onTrigger(m.name, { - trigger, - callback, - mode, - applyToGroup: false, // prevent recursion - }); - } - } else { - // ✅ Apply to just this specific mesh - flock.onTrigger(meshName, { + if (flock.pendingTriggers.has(groupName)) { + const triggers = flock.pendingTriggers.get(groupName); + const remaining = []; + + for (const pending of triggers) { + const { + meshName: pendingMeshName, trigger, callback, mode, - applyToGroup: false, - }); + applyToGroup, + } = pending; + const targetMeshName = pendingMeshName ?? meshName; + + if (applyToGroup) { + // 🔁 Reapply trigger across all matching meshes + const matching = flock.scene.meshes.filter( + (m) => getGroupRoot(m.name) === groupName, + ); + for (const m of matching) { + flock.onTrigger(m.name, { + trigger, + callback, + mode, + applyToGroup: false, // prevent recursion + }); + } + // Keep group-applied triggers pending for future siblings. + remaining.push(pending); + } else { + const guiControl = + flock.scene?.UITexture?.getControlByName?.(targetMeshName) ?? null; + const targetExists = + flock.scene?.getMeshByName(targetMeshName) || guiControl; + + if (targetExists) { + // ✅ Apply to the original target this pending registration was created for. + flock.onTrigger(targetMeshName, { + trigger, + callback, + mode, + applyToGroup: false, + }); + } else { + remaining.push(pending); + } + } + } + + flock.pendingTriggers.set(groupName, remaining); + } + + if (flock.pendingIntersections.has(groupName)) { + const intersections = flock.pendingIntersections.get(groupName); + for (const pending of intersections) { + if ( + meshName !== pending.meshName && + !pending.registeredOthers.has(meshName) + ) { + pending.registeredOthers.add(meshName); + flock.onIntersect(pending.meshName, meshName, { + trigger: pending.trigger, + callback: pending.callback, + applyToGroupOther: false, + }); + } } } }, diff --git a/generators/generators-events.js b/generators/generators-events.js index 6f08d1a1..395758e2 100644 --- a/generators/generators-events.js +++ b/generators/generators-events.js @@ -75,16 +75,20 @@ export function registerEventsGenerators(javascriptGenerator) { const trigger = block.getFieldValue("TRIGGER"); const doCode = javascriptGenerator.statementToCode(block, "DO"); + const isTopLevel = !block.getSurroundParent(); if ( trigger === "OnIntersectionEnterTrigger" || trigger === "OnIntersectionExitTrigger" ) { + const applyToGroupOtherLine = isTopLevel + ? ",\n applyToGroupOther: true" + : ""; return `onIntersect(${modelName}, ${otherModelName}, { trigger: "${trigger}", callback: async function(${modelName}, ${otherModelName}) { ${doCode} - } + }${applyToGroupOtherLine} });\n`; } else { console.error("Invalid trigger type for 'on_collision' block:", trigger); diff --git a/tests/events.test.js b/tests/events.test.js index be1701cc..4fb3dfb2 100644 --- a/tests/events.test.js +++ b/tests/events.test.js @@ -370,6 +370,86 @@ export function runEventsTests(flock) { expect(count).to.equal(0); }); + + it("replays pending non-group trigger on the original target mesh only", async function () { + const target = "latepick_1"; + const sibling = "latepick_2"; + + let count = 0; + flock.onTrigger(target, { + trigger: "OnPickTrigger", + callback: () => count++, + applyToGroup: false, + }); + + await flock.createBox(sibling, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(target, { + width: 1, + height: 1, + depth: 1, + position: [2, 0, 0], + }); + meshIds.push(target, sibling); + + const targetMesh = flock.scene.getMeshByName(target); + const siblingMesh = flock.scene.getMeshByName(sibling); + expect(targetMesh).to.exist; + expect(siblingMesh).to.exist; + + siblingMesh.actionManager?.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + targetMesh.actionManager?.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + + expect(count).to.equal(1); + }); + + it("replays pending group trigger across siblings when applyToGroup is true", async function () { + const first = "lategroup_1"; + const second = "lategroup_2"; + + let count = 0; + flock.onTrigger(first, { + trigger: "OnPickTrigger", + callback: () => count++, + applyToGroup: true, + }); + + await flock.createBox(first, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(second, { + width: 1, + height: 1, + depth: 1, + position: [2, 0, 0], + }); + meshIds.push(first, second); + + const mesh1 = flock.scene.getMeshByName(first); + const mesh2 = flock.scene.getMeshByName(second); + expect(mesh1).to.exist; + expect(mesh2).to.exist; + + mesh1.actionManager?.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + mesh2.actionManager?.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + + expect(count).to.equal(2); + }); }); }); } diff --git a/tests/physics.test.js b/tests/physics.test.js index 747bce54..cae566e7 100644 --- a/tests/physics.test.js +++ b/tests/physics.test.js @@ -112,7 +112,7 @@ export function runPhysicsTests(flock) { let intersected = false; - flock.onIntersect(box1, box2, { + await flock.onIntersect(box1, box2, { trigger: "OnIntersectionEnterTrigger", callback: () => { intersected = true; @@ -131,6 +131,203 @@ export function runPhysicsTests(flock) { expect(intersected).to.be.true; }); + + it("should register intersections for all matching right-hand group meshes", async function () { + const source = "colliderSource_1"; + const groupA = "groupTarget_1"; + const groupB = "groupTarget_2"; + + await flock.createBox(source, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(groupA, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(groupB, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(source, groupA, groupB); + + let count = 0; + await flock.onIntersect(source, groupA, { + trigger: "OnIntersectionEnterTrigger", + applyToGroupOther: true, + callback: () => { + count++; + }, + }); + + const sourceMesh = flock.scene.getMeshByName(source); + const otherA = flock.scene.getMeshByName(groupA); + const otherB = flock.scene.getMeshByName(groupB); + expect(sourceMesh).to.exist; + expect(otherA).to.exist; + expect(otherB).to.exist; + + sourceMesh.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnIntersectionEnterTrigger, + { mesh: otherA }, + ); + sourceMesh.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnIntersectionEnterTrigger, + { mesh: otherB }, + ); + + expect(count).to.equal(2); + }); + + it("should skip self-pair when expanding right-hand collision group", async function () { + const source = "selfPair_1"; + const other = "selfPair_2"; + + await flock.createBox(source, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(other, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(source, other); + + let count = 0; + await flock.onIntersect(source, source, { + trigger: "OnIntersectionEnterTrigger", + applyToGroupOther: true, + callback: () => { + count++; + }, + }); + + const sourceMesh = flock.scene.getMeshByName(source); + const otherMesh = flock.scene.getMeshByName(other); + expect(sourceMesh).to.exist; + expect(otherMesh).to.exist; + + sourceMesh.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnIntersectionEnterTrigger, + { mesh: otherMesh }, + ); + + expect(count).to.equal(1); + }); + + it("should apply right-hand group intersections when targets are created later", async function () { + const source = "lateSource_1"; + const futureGroupSeed = "lateTarget_1"; + const futureGroupOther = "lateTarget_2"; + + await flock.createBox(source, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(source); + + let count = 0; + await flock.onIntersect(source, futureGroupSeed, { + trigger: "OnIntersectionEnterTrigger", + applyToGroupOther: true, + callback: () => { + count++; + }, + }); + + await flock.createBox(futureGroupSeed, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(futureGroupOther, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(futureGroupSeed, futureGroupOther); + + const sourceMesh = flock.scene.getMeshByName(source); + const otherA = flock.scene.getMeshByName(futureGroupSeed); + const otherB = flock.scene.getMeshByName(futureGroupOther); + expect(sourceMesh).to.exist; + expect(otherA).to.exist; + expect(otherB).to.exist; + + sourceMesh.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnIntersectionEnterTrigger, + { mesh: otherA }, + ); + sourceMesh.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnIntersectionEnterTrigger, + { mesh: otherB }, + ); + + expect(count).to.equal(2); + }); + + it("should canonicalize unsanitized RHS names for pending group registration", async function () { + const source = "canonSource_1"; + const unsanitizedAlias = "canon target !@#"; + const normalizedAlias = "canontarget"; + const createdTarget = "canontarget_1"; + + await flock.createBox(source, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(source); + + // Mirror whenModelReady alias support: unsanitized id resolves through + // the normalized key present in modelReadyPromises. + flock.modelReadyPromises.set(normalizedAlias, Promise.resolve(null)); + + let count = 0; + await flock.onIntersect(source, unsanitizedAlias, { + trigger: "OnIntersectionEnterTrigger", + applyToGroupOther: true, + callback: () => { + count++; + }, + }); + + await flock.createBox(createdTarget, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(createdTarget); + + const sourceMesh = flock.scene.getMeshByName(source); + const targetMesh = flock.scene.getMeshByName(createdTarget); + expect(sourceMesh).to.exist; + expect(targetMesh).to.exist; + + sourceMesh.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnIntersectionEnterTrigger, + { mesh: targetMesh }, + ); + + expect(count).to.equal(1); + flock.modelReadyPromises.delete(normalizedAlias); + }); }); describe("applyForce method @physics", function () {