Smart Object Scripting
Smart Objects in Fruit3D can be extended with custom TypeScript/TSX scripts that add interactive behavior. This guide covers the scripting API with practical examples.
For the complete API reference, see the Smart Object API Reference.
Overview
Smart Object scripts are TSX files that run in the browser. They have access to:
SmartObject- The Smart Object API for interacting with the 3D modeluseSmartObject()- React hook to access the API in componentsReact- React libraryTHREE- Three.js libraryuseFrame/useThree- React Three Fiber hooksuseControls- Leva GUI controls
Scripts must have a default export that is a React component.
Basic Script Structure
// Every script must export a default React component
export default function MyScript() {
// Your script logic here
return null; // Scripts don't need to render anything
}
Working with the API
Using the SmartObject Global
The SmartObject global is available directly in your script:
export default function LogModelInfo() {
console.log("Model ID:", SmartObject.modelId);
console.log("API Version:", SmartObject.version);
console.log("Active State:", SmartObject.getActiveStateName());
return null;
}
Using the useSmartObject Hook
For React components that need reactive access:
export default function StateDisplay() {
const api = useSmartObject();
const [stateName, setStateName] = React.useState(api.getActiveStateName());
React.useEffect(() => {
return api.on("stateChanged", ({ stateName }) => {
setStateName(stateName);
});
}, [api]);
console.log("Current state:", stateName);
return null;
}
Handling Click Events
Register click handlers to respond when users click on parts of the model:
export default function ClickHandler() {
React.useEffect(() => {
const unsubscribe = SmartObject.on("click", ({ nodeId, object }) => {
console.log("Clicked node:", nodeId);
console.log("Object name:", object.name);
// Highlight the clicked object
if (object.material) {
object.material.emissive?.setHex(0x333333);
}
});
return unsubscribe;
}, []);
return null;
}
State Management
Switching States
Toggle between predefined states:
export default function StateToggle() {
React.useEffect(() => {
const unsubscribe = SmartObject.on("click", ({ nodeId }) => {
const current = SmartObject.getActiveStateName();
// Toggle between "open" and "closed" states
if (current === "open") {
SmartObject.setActiveStateByName("closed");
} else {
SmartObject.setActiveStateByName("open");
}
});
return unsubscribe;
}, []);
return null;
}
Responding to State Changes
export default function StateChangeLogger() {
React.useEffect(() => {
const unsubscribe = SmartObject.on("stateChanged", ({ stateName, previousStateName }) => {
console.log(`State changed from "${previousStateName}" to "${stateName}"`);
});
return unsubscribe;
}, []);
return null;
}
Accessing 3D Objects
Getting the Root Object
export default function RootAccess() {
React.useEffect(() => {
const root = SmartObject.getRoot();
if (root) {
console.log("Root object:", root.name);
console.log("Children count:", root.children.length);
// Traverse all objects
root.traverse((child) => {
console.log("Child:", child.name, child.type);
});
}
}, []);
return null;
}
Finding Objects by Node ID
export default function NodeAccess() {
React.useEffect(() => {
// Node IDs are JSON paths like "0", "0.1", "0.1.2"
const node = SmartObject.getNodeById("0.1");
if (node) {
console.log("Found node:", node.name);
// Modify the object
node.visible = false;
}
}, []);
return null;
}
Getting Node ID from Object
export default function ReverseNodeLookup() {
React.useEffect(() => {
const unsubscribe = SmartObject.on("click", ({ object }) => {
const nodeId = SmartObject.getNodeId(object);
console.log("Clicked object's node ID:", nodeId);
});
return unsubscribe;
}, []);
return null;
}
Selection Management
Getting Current Selection
export default function SelectionInfo() {
const logSelection = () => {
const selection = SmartObject.getSelection();
if (selection) {
console.log("Selected node:", selection.nodeId);
console.log("Selected object:", selection.object.name);
} else {
console.log("Nothing selected");
}
};
React.useEffect(() => {
logSelection();
}, []);
return null;
}
Setting Selection Programmatically
export default function AutoSelect() {
React.useEffect(() => {
// Select a specific node when the script loads
SmartObject.setSelection("0.1");
// Clear selection after 3 seconds
const timer = setTimeout(() => {
SmartObject.setSelection(null);
}, 3000);
return () => clearTimeout(timer);
}, []);
return null;
}
Animation with useFrame
Use React Three Fiber's useFrame for per-frame updates:
export default function RotatingPart() {
const nodeRef = React.useRef(null);
React.useEffect(() => {
nodeRef.current = SmartObject.getNodeById("0.1");
}, []);
useFrame((state, delta) => {
if (nodeRef.current) {
nodeRef.current.rotation.y += delta * 0.5;
}
});
return null;
}
Animated State Transitions
export default function SmoothTransition() {
const targetY = React.useRef(0);
const currentY = React.useRef(0);
const node = React.useRef(null);
React.useEffect(() => {
node.current = SmartObject.getNodeById("0.1");
const unsubscribe = SmartObject.on("stateChanged", ({ stateName }) => {
targetY.current = stateName === "raised" ? 2 : 0;
});
return unsubscribe;
}, []);
useFrame((state, delta) => {
if (!node.current) return;
// Lerp toward target
currentY.current = THREE.MathUtils.lerp(
currentY.current,
targetY.current,
delta * 5
);
node.current.position.y = currentY.current;
});
return null;
}
GUI Controls with Leva
Add interactive controls for testing and debugging:
export default function DebugControls() {
const { speed, color, visible } = useControls({
speed: { value: 1, min: 0, max: 5, step: 0.1 },
color: "#ff0000",
visible: true,
});
React.useEffect(() => {
const node = SmartObject.getNodeById("0.1");
if (node) {
node.visible = visible;
if (node.material) {
node.material.color.set(color);
}
}
}, [color, visible]);
const nodeRef = React.useRef(null);
React.useEffect(() => {
nodeRef.current = SmartObject.getNodeById("0.1");
}, []);
useFrame((state, delta) => {
if (nodeRef.current) {
nodeRef.current.rotation.y += delta * speed;
}
});
return null;
}
Complete Example: Interactive Drawer
This example combines multiple API features to create an interactive drawer:
export default function InteractiveDrawer() {
const drawerNode = React.useRef(null);
const isOpen = React.useRef(false);
const targetZ = React.useRef(0);
// Get the drawer node on mount
React.useEffect(() => {
drawerNode.current = SmartObject.getNodeById("0.2"); // drawer node
}, []);
// Handle clicks to toggle drawer
React.useEffect(() => {
const unsubscribe = SmartObject.on("click", ({ nodeId }) => {
// Check if drawer or handle was clicked
if (nodeId === "0.2" || nodeId === "0.2.1") {
isOpen.current = !isOpen.current;
targetZ.current = isOpen.current ? 0.5 : 0;
// Also update the official state
SmartObject.setActiveStateByName(isOpen.current ? "open" : "closed");
}
});
return unsubscribe;
}, []);
// Sync with external state changes
React.useEffect(() => {
const unsubscribe = SmartObject.on("stateChanged", ({ stateName }) => {
isOpen.current = stateName === "open";
targetZ.current = isOpen.current ? 0.5 : 0;
});
return unsubscribe;
}, []);
// Animate the drawer smoothly
useFrame((state, delta) => {
if (!drawerNode.current) return;
const current = drawerNode.current.position.z;
const target = targetZ.current;
if (Math.abs(current - target) > 0.001) {
drawerNode.current.position.z = THREE.MathUtils.lerp(
current,
target,
delta * 8
);
}
});
return null;
}
Script Restrictions
For security, scripts have the following restrictions:
- No imports - Cannot use
importorrequire()statements - No dynamic imports - Cannot use
import() - Must have default export - The default export must be a React component
All dependencies are provided via globals (React, THREE, R3F, SmartObject, useControls).
Error Handling
Script errors are caught by an error boundary and reported to the editor. Use try-catch for operations that might fail:
export default function SafeScript() {
React.useEffect(() => {
try {
const node = SmartObject.getNodeById("0.1");
if (!node) {
console.warn("Node not found");
return;
}
// Safe to use node here
} catch (error) {
console.error("Script error:", error);
}
}, []);
return null;
}
Tips and Best Practices
- Clean up effects - Always return cleanup functions from
useEffectto unsubscribe event listeners - Use refs for animation - Store objects in refs to avoid re-querying every frame
- Check for null - Node lookups may return null if the ID doesn't exist
- Test in preview mode - State changes only work in preview mode, not edit mode
- Use Leva for debugging - Add temporary controls to test different values quickly
- State names -
getActiveStateName()andsetActiveStateByName()work with state names, not IDs
Next Steps
- See the Smart Object API Reference for complete type definitions