adk-ui 2.1.0

Dynamic UI generation for ADK-Rust agents - render forms, cards, tables, charts and more
Documentation
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import type { Scene3dComponent, Scene3dMaterialRole, UiEvent } from './types';
import { useAdkUiKit } from './kit';
import { sanitizeUrl } from './security';

interface Scene3DRendererProps {
    component: Scene3dComponent;
    onAction?: (event: UiEvent) => void;
}

interface RenderEngine {
    domElement: HTMLCanvasElement;
    setPixelRatio(value: number): void;
    setSize(width: number, height: number, updateStyle?: boolean): void;
    render(scene: THREE.Scene, camera: THREE.Camera): void;
    dispose(): void;
}

const DEFAULT_POSITION: [number, number, number] = [4.5, 3, 6];
const DEFAULT_TARGET: [number, number, number] = [0, 0, 0];
const DEFAULT_SCALE: [number, number, number] = [1, 1, 1];

function applyTransform(
    target: THREE.Object3D,
    position = DEFAULT_TARGET,
    rotation = DEFAULT_TARGET,
    scale = DEFAULT_SCALE,
) {
    target.position.set(...position);
    target.rotation.set(...rotation);
    target.scale.set(...scale);
}

function geometryFor(shape: Extract<Scene3dComponent['objects'][number], { kind: 'primitive' }>['shape']) {
    switch (shape) {
        case 'sphere': return new THREE.SphereGeometry(0.75, 40, 24);
        case 'cylinder': return new THREE.CylinderGeometry(0.65, 0.65, 1.4, 36);
        case 'torus': return new THREE.TorusGeometry(0.72, 0.22, 24, 64);
        case 'plane': return new THREE.PlaneGeometry(2, 2, 12, 12);
        case 'box': return new THREE.BoxGeometry(1.25, 1.25, 1.25, 4, 4, 4);
    }
}

function colorFor(role: Scene3dMaterialRole | undefined, colors: ReturnType<typeof useAdkUiKit>['manifest']['tokens']['colors']) {
    switch (role) {
        case 'secondary': return colors.secondary;
        case 'surface': return colors.surface;
        case 'muted': return colors.muted;
        case 'success': return colors.success;
        case 'warning': return colors.warning;
        case 'info': return colors.info;
        case 'primary':
        default: return colors.primary;
    }
}

function disposeObject(object: THREE.Object3D) {
    object.traverse((child) => {
        if (!(child instanceof THREE.Mesh)) return;
        child.geometry.dispose();
        const materials = Array.isArray(child.material) ? child.material : [child.material];
        for (const material of materials) {
            for (const value of Object.values(material)) {
                if (value instanceof THREE.Texture) value.dispose();
            }
            material.dispose();
        }
    });
}

async function createRenderer(): Promise<RenderEngine> {
    try {
        if (!(typeof navigator !== 'undefined' && 'gpu' in navigator)) {
            throw new Error('WebGPU is unavailable');
        }
        const module = await import('three/webgpu');
        const renderer = new module.WebGPURenderer({ antialias: true, alpha: true });
        await renderer.init();
        return renderer as unknown as RenderEngine;
    } catch {
        return new THREE.WebGLRenderer({ antialias: true, alpha: true });
    }
}

export default function Scene3DRenderer({ component, onAction }: Scene3DRendererProps) {
    const mountRef = useRef<HTMLDivElement>(null);
    const onActionRef = useRef(onAction);
    const { manifest } = useAdkUiKit();
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        onActionRef.current = onAction;
    }, [onAction]);

    const height = Math.min(960, Math.max(240, component.height ?? 440));
    const labelledObjects = component.objects.filter((object) => object.label);

    useEffect(() => {
        const mount = mountRef.current;
        if (!mount || component.objects.length === 0 || component.objects.length > 64) return;
        let disposed = false;
        let renderer: RenderEngine | null = null;
        let controls: OrbitControls | null = null;
        let animationFrame = 0;
        let visible = true;
        const scene = new THREE.Scene();
        const world = new THREE.Group();
        const interactive: THREE.Object3D[] = [];
        scene.add(world);

        const backgroundRole = component.background ?? 'surface';
        if (backgroundRole !== 'transparent') {
            const background = backgroundRole === 'contrast'
                ? manifest.tokens.colors.foreground
                : backgroundRole === 'muted'
                    ? manifest.tokens.colors.muted
                    : manifest.tokens.colors.surface;
            scene.background = new THREE.Color(background);
        }

        const camera = new THREE.PerspectiveCamera(
            Math.min(90, Math.max(20, component.camera?.fov ?? 42)),
            1,
            0.1,
            1000,
        );
        camera.position.set(...(component.camera?.position ?? DEFAULT_POSITION));
        camera.lookAt(...(component.camera?.target ?? DEFAULT_TARGET));

        scene.add(new THREE.HemisphereLight('#ffffff', manifest.tokens.colors.muted, 2.1));
        const keyLight = new THREE.DirectionalLight('#ffffff', 3.2);
        keyLight.position.set(4, 7, 6);
        scene.add(keyLight);
        const rimLight = new THREE.PointLight(manifest.tokens.colors.primary, 18, 24);
        rimLight.position.set(-4, 1, -3);
        scene.add(rimLight);

        for (const object of component.objects.slice(0, 64)) {
            if (object.kind === 'primitive') {
                const mesh = new THREE.Mesh(
                    geometryFor(object.shape),
                    new THREE.MeshStandardMaterial({
                        color: colorFor(object.material, manifest.tokens.colors),
                        metalness: object.material === 'surface' ? 0.15 : 0.35,
                        roughness: object.material === 'surface' ? 0.58 : 0.34,
                    }),
                );
                mesh.name = object.id;
                mesh.userData.actionId = object.action_id;
                applyTransform(mesh, object.position, object.rotation, object.scale);
                world.add(mesh);
                if (object.action_id) interactive.push(mesh);
                continue;
            }

            const asset = manifest.assets.find((candidate) => candidate.id === object.asset_id);
            if (!asset || asset.kind !== 'model_3d' || asset.mimeType !== 'model/gltf-binary') continue;
            const source = sanitizeUrl(asset.uri, 'media');
            if (!source) continue;
            void new GLTFLoader().loadAsync(source).then((gltf) => {
                if (disposed) {
                    disposeObject(gltf.scene);
                    return;
                }
                gltf.scene.name = object.id;
                gltf.scene.userData.actionId = object.action_id;
                applyTransform(gltf.scene, object.position, object.rotation, object.scale);
                gltf.scene.traverse((child) => { child.userData.actionId = object.action_id; });
                world.add(gltf.scene);
                if (object.action_id) interactive.push(gltf.scene);
            }).catch(() => undefined);
        }

        const pointer = new THREE.Vector2();
        const raycaster = new THREE.Raycaster();
        const handlePointer = (event: PointerEvent) => {
            if (!renderer || interactive.length === 0) return;
            const rect = renderer.domElement.getBoundingClientRect();
            pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
            pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
            raycaster.setFromCamera(pointer, camera);
            const hit = raycaster.intersectObjects(interactive, true)[0]?.object;
            const actionId = hit?.userData.actionId as string | undefined;
            if (actionId) {
                onActionRef.current?.({ action: 'button_click', action_id: actionId, source_component_id: hit.name || component.id });
            }
        };

        const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        const clock = new THREE.Clock();
        const animate = () => {
            if (disposed) return;
            if (visible && renderer) {
                if (component.auto_rotate && !reducedMotion) world.rotation.y += clock.getDelta() * 0.16;
                else clock.getDelta();
                controls?.update();
                renderer.render(scene, camera);
            }
            animationFrame = requestAnimationFrame(animate);
        };

        const resizeObserver = new ResizeObserver(([entry]) => {
            if (!renderer || !entry) return;
            const width = Math.max(1, entry.contentRect.width);
            renderer.setSize(width, height, false);
            camera.aspect = width / height;
            camera.updateProjectionMatrix();
        });
        const intersectionObserver = new IntersectionObserver(([entry]) => {
            visible = entry?.isIntersecting ?? true;
        });

        void createRenderer().then((createdRenderer) => {
            if (disposed) {
                createdRenderer.dispose();
                return;
            }
            renderer = createdRenderer;
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
            renderer.domElement.setAttribute('aria-hidden', 'true');
            renderer.domElement.addEventListener('pointerdown', handlePointer);
            mount.appendChild(renderer.domElement);
            controls = new OrbitControls(camera, renderer.domElement);
            controls.enabled = component.controls ?? true;
            controls.enableDamping = true;
            controls.target.set(...(component.camera?.target ?? DEFAULT_TARGET));
            controls.update();
            resizeObserver.observe(mount);
            intersectionObserver.observe(mount);
            animationFrame = requestAnimationFrame(animate);
        }).catch(() => setError(component.fallback ?? 'This device cannot render the 3D scene.'));

        return () => {
            disposed = true;
            cancelAnimationFrame(animationFrame);
            resizeObserver.disconnect();
            intersectionObserver.disconnect();
            controls?.dispose();
            if (renderer) {
                renderer.domElement.removeEventListener('pointerdown', handlePointer);
                renderer.domElement.remove();
                renderer.dispose();
            }
            disposeObject(world);
        };
    }, [component, height, manifest]);

    return (
        <figure className="adk-ui-scene-3d" style={{ minHeight: height }}>
            {(component.title || component.description) && (
                <figcaption className="adk-ui-scene-3d__caption">
                    {component.title && <strong>{component.title}</strong>}
                    {component.description && <span>{component.description}</span>}
                </figcaption>
            )}
            {error ? (
                <div className="adk-ui-scene-3d__fallback" role="status">{error}</div>
            ) : (
                <div ref={mountRef} className="adk-ui-scene-3d__viewport" style={{ height }} />
            )}
            {labelledObjects.length > 0 && (
                <div className="adk-ui-scene-3d__actions" aria-label="3D scene objects">
                    {labelledObjects.map((object) => object.action_id ? (
                        <button key={object.id} type="button" onClick={() => onAction?.({ action: 'button_click', action_id: object.action_id!, source_component_id: object.id })}>
                            {object.label}
                        </button>
                    ) : <span key={object.id}>{object.label}</span>)}
                </div>
            )}
        </figure>
    );
}