Skip to main content

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 model
  • useSmartObject() - React hook to access the API in components
  • React - React library
  • THREE - Three.js library
  • useFrame / useThree - React Three Fiber hooks
  • useControls - 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 import or require() 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

  1. Clean up effects - Always return cleanup functions from useEffect to unsubscribe event listeners
  2. Use refs for animation - Store objects in refs to avoid re-querying every frame
  3. Check for null - Node lookups may return null if the ID doesn't exist
  4. Test in preview mode - State changes only work in preview mode, not edit mode
  5. Use Leva for debugging - Add temporary controls to test different values quickly
  6. State names - getActiveStateName() and setActiveStateByName() work with state names, not IDs

Next Steps