Skip to content

Commit a86c7f5

Browse files
Merge pull request #8 from MakingSpiderSense/feature/holdable/92-prop-change-on-grip-and-release
holdable: add support for modifiers on grip and release
2 parents d5f5bca + 8426fbf commit a86c7f5

3 files changed

Lines changed: 133 additions & 12 deletions

File tree

dist/mss-aframe-kit.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/holdable/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ Add the `holdable` component to an entity you'd like to make grabbable, such as
3333
| position | vec3 | Local offset position where the object should be held (relative to controller). | `0 0 0` | Any position coordinates (in meters) |
3434
| rotation | vec3 | Local offset rotation applied when held (relative to controller). | `0 0 0` | Any rotation angles (in degrees) |
3535

36-
**Note**: Left-hand interactions are mirrored - position and rotation offsets are automatically flipped when grabbing with the left hand.
36+
**Notes**:
37+
38+
- Left-hand interactions are mirrored - position and rotation offsets are automatically flipped when grabbing with the left hand.
39+
- Using "0 0 0" for position or rotation will indicate no custom position or rotation. For rotation, this means the rotation will be the same as the object's original rotation when grabbed.
3740

3841
## Behavior & Features
3942

@@ -51,9 +54,11 @@ Add the `holdable` component to an entity you'd like to make grabbable, such as
5154

5255
- You don't need to manually add the intersection class (`.interactable`) - `holdable` adds it automatically.
5356
- It's recommended to use the `post-model-load-refresh` component (part of `mss-aframe-kit`) if working with GLTF models to ensure raycasters and physics bodies are refreshed post-load.
57+
- If using a custom rotation and position, just know that rotation pivots around the controller, not the model's center. It's easiest to set the custom rotation before position.
5458

5559
## Advanced Usage
5660

61+
- **Grip/Release Property Modifiers:** Use the `holdable-grip-` and `holdable-release-` prefixes to apply component modifications on grip and release, respectively. This allows you to change properties like color, scale, or even apply animations when the object is held or released (e.g. `holdable-grip-material="color: blue; opacity: .1"`).
5762
- **Intersection Class Customization:** Use `data-holdable-intersection-class` on `<a-scene>` to change the default `.interactable` class used for raycaster targeting.
5863
- **Custom Shape Collision Support:** Preserves and restores multiple `shape__*` components with their configs ([Learn More](https://github.com/c-frame/aframe-physics-system/blob/master/CannonDriver.md#shape)).
5964
- **Supports Sleepy Physics Objects:** The `sleepy` component from the `aframe-physics-extras` library can be used to reduce jitter for objects that are stacked ([Learn More](https://github.com/wmurphyrd/aframe-physics-extras?tab=readme-ov-file#sleepy)).

src/components/holdable/holdable.js

Lines changed: 126 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,9 @@
33
*
44
* Overview: A component that allows an object to be picked up and held by a controller.
55
*
6-
* Description: This component makes an object "holdable" by VR controllers using raycaster events targeted at objects with the specified intersection class (defaults to "interactable"). When the controller's ray intersects the object, it listens for grip events. On grip down, the component saves any physics settings and re-parents the object to the controller, aligning it based on a local-custom position/rotation (from its schema) or a global default provided by a scene attribute (like `data-holdable-grab-position="0 0 0"`). Rotation pivots around the controller, not the model's center. It's easiest to set the custom rotation before position. If local-custom or global positions are not set, it defaults to where it was actually grabbed (local-computed). On grip up, it restores the original physics and parent. If the object has a `holdable-dynamic-body` attribute, it applies dynamic-body properties after release, even if the object was previously static.
6+
* Description: This component makes an object "holdable" by VR controllers using raycaster events targeted at objects with the specified intersection class (defaults to "interactable"). When the controller's ray intersects the object, it listens for grip events. On grip down, the component saves any physics settings and re-parents the object to the controller, optionally aligning it based on a local-custom position/rotation (from its schema) or a global default provided by a scene attribute (like `data-holdable-grab-position="0 0 0"`). On grip up, it restores the original physics and parent.
77
*
8-
* Notes:
9-
* - If using local-computed, using "0 0 0" for position or rotation will indicate no custom position or rotation. For rotation, this means the rotation will be the same as the object's original rotation when grabbed.
10-
* - Tip: You don't actually need to add the intersection class to the entity, as the holdable component will add it automatically if it is not already present. However, you can add it manually if you want to use the intersection class for other purposes.
11-
* - Tip: If using models with textures, it's recommended to include the `post-model-load-refresh` component on the scene to ensure raycasters and physics bodies are refreshed after all models have loaded.
12-
* - Rare: If you want to use another class for raycaster intersections instead of "interactable", you can add it globally to the scene using `data-holdable-intersection-class="your-class"`. This will be used for all holdable objects unless overridden.
13-
*
14-
* Limitations:
15-
* - While there is some code in here showing support for the ammo.js driver, it is not working correctly. After release, collisions no longer work.
16-
* - There is no way to grab an object with both hands at the same time. User must let go of one hand before grabbing with the other.
8+
* More info: https://github.com/MakingSpiderSense/mss-aframe-kit/tree/main/docs/holdable
179
*/
1810
AFRAME.registerComponent("holdable", {
1911
schema: {
@@ -34,6 +26,10 @@ AFRAME.registerComponent("holdable", {
3426
this.insideMesh = {}; // Object to track if a either hand is inside the mesh.
3527
this.insideTestRaycaster = new THREE.Raycaster(); // Temporary raycaster for inside-mesh test.
3628
this.insideTestRaycaster.far = 10;
29+
this.savedComponentStates = {}; // Modifiers - Store original component states
30+
this.gripModifiers = {}; // Modifiers - Store grip modifiers
31+
this.releaseModifiers = {}; // Modifiers - Store release modifiers
32+
this.scanModifierAttributes(); // Modifiers - Scan for grip and release modifiers
3733
this.onGripDown = this.onGripDown.bind(this);
3834
this.onGripUp = this.onGripUp.bind(this);
3935
this.onHitStart = this.onHitStart.bind(this);
@@ -52,6 +48,102 @@ AFRAME.registerComponent("holdable", {
5248
this.el.classList.add(intersectionClass);
5349
}
5450
},
51+
// Modifiers - Scan for grip and release modifier attributes
52+
scanModifierAttributes: function() {
53+
const attributes = this.el.getAttributeNames();
54+
for (let attr of attributes) {
55+
// Check for grip modifiers (holdable-grip-componentName)
56+
if (attr.startsWith('holdable-grip-')) {
57+
const componentName = attr.substring('holdable-grip-'.length); // Remove the prefix
58+
const attributeString = this.el.getAttribute(attr);
59+
// Parse the string into an object
60+
const parsedProps = this.parseAttributeString(attributeString);
61+
this.gripModifiers[componentName] = parsedProps;
62+
}
63+
// Check for release modifiers (holdable-release-componentName)
64+
else if (attr.startsWith('holdable-release-')) {
65+
const componentName = attr.substring('holdable-release-'.length); // Remove the prefix
66+
const attributeString = this.el.getAttribute(attr);
67+
// Parse the string into an object
68+
const parsedProps = this.parseAttributeString(attributeString);
69+
this.releaseModifiers[componentName] = parsedProps;
70+
}
71+
}
72+
},
73+
// Modifiers - Parse an A-Frame attribute string into a JavaScript object or direct value
74+
parseAttributeString: function(attributeString) {
75+
// Handle flag components (e.g. light)
76+
if (!attributeString || attributeString.trim() === '') {
77+
return { __is_flag: true };
78+
}
79+
// If the string doesn't contain a colon, it's a direct value (e.g. scale="3 1 2")
80+
if (attributeString.indexOf(':') === -1) {
81+
return { __direct_value: attributeString.trim() };
82+
}
83+
const result = {};
84+
// Split by semicolons and then by colons to get key-value pairs (e.g. material="color: red; opacity: 0.5")
85+
const kvPairs = attributeString.split(';');
86+
for (let kvPair of kvPairs) {
87+
if (!kvPair.trim()) continue;
88+
// Split by the first colon to separate key and value
89+
const colonIndex = kvPair.indexOf(':');
90+
if (colonIndex === -1) continue;
91+
const key = kvPair.substring(0, colonIndex).trim();
92+
let value = kvPair.substring(colonIndex + 1).trim();
93+
// Convert value to appropriate type
94+
if (value === 'true') value = true;
95+
else if (value === 'false') value = false;
96+
else if (!isNaN(parseFloat(value)) && isFinite(value)) {
97+
value = parseFloat(value);
98+
}
99+
result[key] = value;
100+
}
101+
return result;
102+
},
103+
// Modifiers - Apply component modifications handling different component types
104+
// Note: newProps can be a flag component (e.g., __is_flag: true), a direct value component (e.g., __direct_value: "3 1 2"), or a property-based component with many properties (e.g., { prop1: "value1", prop2: "value2" })
105+
applyComponentModifications: function(componentName, newProps, saveOriginal = false) {
106+
// Skip if new props is empty or undefined
107+
if (!newProps || Object.keys(newProps).length === 0) return;
108+
// Save original state if needed and not already saved
109+
if (saveOriginal && !this.savedComponentStates[componentName]) {
110+
if (this.el.hasAttribute(componentName)) {
111+
this.savedComponentStates[componentName] = AFRAME.utils.clone(this.el.getAttribute(componentName));
112+
} else {
113+
// Mark that the component didn't exist
114+
this.savedComponentStates[componentName] = null;
115+
}
116+
}
117+
// Handle different types of component values
118+
if (newProps.__is_flag) {
119+
// It's a flag component, just add it without values
120+
this.el.setAttribute(componentName, '');
121+
}
122+
else if (newProps.__direct_value) {
123+
// It's a direct value component like position="3 1 2"
124+
this.el.setAttribute(componentName, newProps.__direct_value);
125+
}
126+
else {
127+
// It's a property-based component
128+
// Apply each property individually to ensure it's properly set
129+
for (const propName in newProps) {
130+
this.el.setAttribute(componentName, propName, newProps[propName]);
131+
}
132+
}
133+
},
134+
// Modifiers - Restore original component state
135+
restoreComponentState: function(componentName) {
136+
if (componentName in this.savedComponentStates) {
137+
const originalState = this.savedComponentStates[componentName];
138+
if (originalState === null) {
139+
// Component didn't exist originally, remove it
140+
this.el.removeAttribute(componentName);
141+
} else {
142+
// Restore the original state
143+
this.el.setAttribute(componentName, originalState);
144+
}
145+
}
146+
},
55147
tick: function (time, delta) {
56148
// If held, track the hand's world position to compute velocity.
57149
if (this.isHeld && this.holdingHand) {
@@ -124,6 +216,14 @@ AFRAME.registerComponent("holdable", {
124216
hand: handEl,
125217
entity: this.el,
126218
});
219+
// Modifiers - Apply all grip modifiers and save original states
220+
for (const componentName in this.gripModifiers) {
221+
this.applyComponentModifications(
222+
componentName,
223+
this.gripModifiers[componentName],
224+
true, // Save original state
225+
);
226+
}
127227
this.holdingHand = handEl;
128228
// Save physics attributes if they exist.
129229
if (this.el.hasAttribute("dynamic-body")) {
@@ -350,6 +450,22 @@ AFRAME.registerComponent("holdable", {
350450
}
351451
}
352452
}, 50);
453+
// Modifiers - Apply all release modifiers
454+
for (const componentName in this.releaseModifiers) {
455+
this.applyComponentModifications(
456+
componentName,
457+
this.releaseModifiers[componentName],
458+
false // Don't save original state
459+
);
460+
}
461+
// Modifiers - Restore original states for components that don't have a release modifier
462+
for (const componentName in this.savedComponentStates) {
463+
if (!(componentName in this.releaseModifiers)) {
464+
this.restoreComponentState(componentName);
465+
}
466+
}
467+
// Modifiers - Clear saved component states
468+
this.savedComponentStates = {};
353469
// Simulate pulling the raycaster away by temporarily setting the raycaster's far value to 0, then restoring it. This lets the user grab the object again without moving the controller away first.
354470
const handEls = document.querySelectorAll("[meta-touch-controls], [oculus-touch-controls], [hand-controls]");
355471
if (handEls) {

0 commit comments

Comments
 (0)