Digital Twin Page (3D Viewer)

Full-page 3D viewer for inspecting the Digital Twin of the hardware product. Features include a collapsible component tree, interactive part selection with BOM annotations, exploded-view controls, and STEP-to-GLB conversion via a Web Worker. In Phase 3 (L3), the viewer will support live state overlays from device telemetry.

Table of contents

  1. src/pages/digital-twin/index.tsx
  2. src/pages/digital-twin/components/model-viewer.tsx
  3. src/pages/digital-twin/components/model-loader.tsx
  4. src/pages/digital-twin/components/component-tree.tsx
  5. src/pages/digital-twin/components/annotation-overlay.tsx
  6. src/pages/digital-twin/components/exploded-view-controls.tsx
  7. src/pages/digital-twin/components/step-converter-worker.ts
  8. Live State Overlay (L3 — Future)
    1. WebSocket Subscription
    2. Integration Architecture
    3. Use Cases

src/pages/digital-twin/index.tsx

Full-page layout with left sidebar (component tree), center 3D canvas, bottom toolbar (exploded view controls), and right panel (BOM annotation).

import { useState, useCallback, Suspense } from "react";
import { PageHeader } from "@/components/layout/page-header";
import { ModelViewer } from "@/pages/digital-twin/components/model-viewer";
import { ComponentTree } from "@/pages/digital-twin/components/component-tree";
import { ExplodedViewControls } from "@/pages/digital-twin/components/exploded-view-controls";
import { AnnotationOverlay } from "@/pages/digital-twin/components/annotation-overlay";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { LoadingSpinner } from "@/components/shared/loading-spinner";
import { cn } from "@/lib/utils";
import {
  PanelLeftClose,
  PanelLeftOpen,
  PanelRightClose,
  PanelRightOpen,
} from "lucide-react";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface PartNode {
  id: string;
  name: string;
  children: PartNode[];
  visible: boolean;
  meshName?: string;
}

export interface SelectedPartInfo {
  id: string;
  name: string;
  mpn?: string;
  manufacturer?: string;
  price?: number;
  stock?: number;
  lifecycle?: string;
}

// ---------------------------------------------------------------------------
// Demo data
// ---------------------------------------------------------------------------

const DEMO_GLB_URL = "/sample-models/dfc-v2.1.glb";

const DEMO_PARTS: PartNode[] = [
  {
    id: "frame",
    name: "Frame Assembly",
    visible: true,
    children: [
      { id: "frame-top", name: "Top Plate", visible: true, meshName: "TopPlate", children: [] },
      { id: "frame-bottom", name: "Bottom Plate", visible: true, meshName: "BottomPlate", children: [] },
      { id: "frame-standoff-1", name: "Standoff L", visible: true, meshName: "StandoffL", children: [] },
      { id: "frame-standoff-2", name: "Standoff R", visible: true, meshName: "StandoffR", children: [] },
    ],
  },
  {
    id: "pcb",
    name: "Flight Controller PCB",
    visible: true,
    children: [
      { id: "pcb-mcu", name: "MCU (STM32H743)", visible: true, meshName: "MCU", children: [] },
      { id: "pcb-imu", name: "IMU (BMI270)", visible: true, meshName: "IMU", children: [] },
      { id: "pcb-baro", name: "Barometer (BMP390)", visible: true, meshName: "Baro", children: [] },
      { id: "pcb-gps", name: "GPS Module", visible: true, meshName: "GPS", children: [] },
    ],
  },
  {
    id: "connectors",
    name: "Connectors",
    visible: true,
    children: [
      { id: "conn-usb", name: "USB-C Port", visible: true, meshName: "USBC", children: [] },
      { id: "conn-esc", name: "ESC Header", visible: true, meshName: "ESCHeader", children: [] },
      { id: "conn-rx", name: "RX Port", visible: true, meshName: "RXPort", children: [] },
    ],
  },
  {
    id: "enclosure",
    name: "Protective Enclosure",
    visible: true,
    children: [
      { id: "enc-shell", name: "Shell", visible: true, meshName: "Shell", children: [] },
      { id: "enc-lid", name: "Lid", visible: true, meshName: "Lid", children: [] },
    ],
  },
];

// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------

export function DigitalTwinPage() {
  // ---- state --------------------------------------------------------------
  const [leftPanelOpen, setLeftPanelOpen] = useState(true);
  const [rightPanelOpen, setRightPanelOpen] = useState(false);
  const [parts, setParts] = useState<PartNode[]>(DEMO_PARTS);
  const [selectedPartId, setSelectedPartId] = useState<string | null>(null);
  const [hoveredPartId, setHoveredPartId] = useState<string | null>(null);
  const [explodeFactor, setExplodeFactor] = useState(0);
  const [wireframe, setWireframe] = useState(false);
  const [showLabels, setShowLabels] = useState(false);

  // ---- selected part info for annotation panel ----------------------------
  const [selectedPartInfo, setSelectedPartInfo] =
    useState<SelectedPartInfo | null>(null);

  // ---- callbacks ----------------------------------------------------------
  const handlePartClick = useCallback(
    (partId: string) => {
      setSelectedPartId(partId);
      setRightPanelOpen(true);

      // Look up part info (in production this would come from BOM data)
      const flatParts = flattenParts(parts);
      const part = flatParts.find((p) => p.id === partId);
      if (part) {
        setSelectedPartInfo({
          id: part.id,
          name: part.name,
          mpn: "DEMO-MPN-001",
          manufacturer: "Demo Manufacturer",
          price: 2.45,
          stock: 1200,
          lifecycle: "active",
        });
      }
    },
    [parts],
  );

  const handlePartHover = useCallback((partId: string | null) => {
    setHoveredPartId(partId);
  }, []);

  const handleVisibilityToggle = useCallback(
    (partId: string, visible: boolean) => {
      setParts((prev) => toggleVisibility(prev, partId, visible));
    },
    [],
  );

  const handleResetView = useCallback(() => {
    setExplodeFactor(0);
    setSelectedPartId(null);
    setSelectedPartInfo(null);
    setRightPanelOpen(false);
    setWireframe(false);
    setShowLabels(false);
  }, []);

  const handleScreenshot = useCallback(() => {
    const canvas = document.querySelector("canvas");
    if (!canvas) return;
    const link = document.createElement("a");
    link.download = "digital-twin-screenshot.png";
    link.href = canvas.toDataURL("image/png");
    link.click();
  }, []);

  // ---- visible part IDs set for the 3D viewer ----------------------------
  const visiblePartIds = new Set(
    flattenParts(parts)
      .filter((p) => p.visible)
      .map((p) => p.id),
  );

  return (
    <div className="flex h-[calc(100vh-8rem)] flex-col">
      {/* Top bar */}
      <div className="flex items-center justify-between border-b px-4 py-2">
        <PageHeader
          title="Digital Twin"
          description="DFC-v2.1 — Interactive 3D Model"
        />
        <div className="flex items-center gap-1">
          <Button
            variant="ghost"
            size="icon"
            onClick={() => setLeftPanelOpen(!leftPanelOpen)}
            title={leftPanelOpen ? "Hide component tree" : "Show component tree"}
          >
            {leftPanelOpen ? (
              <PanelLeftClose className="h-4 w-4" />
            ) : (
              <PanelLeftOpen className="h-4 w-4" />
            )}
          </Button>
          <Button
            variant="ghost"
            size="icon"
            onClick={() => setRightPanelOpen(!rightPanelOpen)}
            title={rightPanelOpen ? "Hide BOM details" : "Show BOM details"}
          >
            {rightPanelOpen ? (
              <PanelRightClose className="h-4 w-4" />
            ) : (
              <PanelRightOpen className="h-4 w-4" />
            )}
          </Button>
        </div>
      </div>

      {/* Main layout */}
      <div className="flex flex-1 overflow-hidden">
        {/* Left sidebar: Component Tree */}
        {leftPanelOpen && (
          <aside className="w-64 shrink-0 overflow-y-auto border-r bg-card">
            <ComponentTree
              parts={parts}
              selectedPartId={selectedPartId}
              onSelect={handlePartClick}
              onVisibilityToggle={handleVisibilityToggle}
            />
          </aside>
        )}

        {/* Center: 3D Canvas */}
        <div className="relative flex-1">
          <Suspense
            fallback={
              <div className="flex h-full items-center justify-center bg-muted/20">
                <LoadingSpinner size="lg" label="Loading 3D model..." />
              </div>
            }
          >
            <ModelViewer
              glbUrl={DEMO_GLB_URL}
              selectedPart={selectedPartId}
              hoveredPart={hoveredPartId}
              explodeFactor={explodeFactor}
              wireframe={wireframe}
              showLabels={showLabels}
              visibleParts={visiblePartIds}
              onPartClick={handlePartClick}
              onPartHover={handlePartHover}
            />
          </Suspense>

          {/* Hovered part annotation overlay */}
          {hoveredPartId && (
            <div className="pointer-events-none absolute left-4 top-4 z-10">
              <div className="rounded-md border bg-card/90 px-3 py-1.5 text-sm shadow-lg backdrop-blur-sm">
                {flattenParts(parts).find((p) => p.id === hoveredPartId)?.name ??
                  hoveredPartId}
              </div>
            </div>
          )}
        </div>

        {/* Right panel: BOM Annotation */}
        {rightPanelOpen && selectedPartInfo && (
          <aside className="w-72 shrink-0 overflow-y-auto border-l bg-card">
            <AnnotationOverlay
              part={selectedPartInfo}
              onClose={() => setRightPanelOpen(false)}
            />
          </aside>
        )}
      </div>

      {/* Bottom toolbar: Exploded View Controls */}
      <ExplodedViewControls
        explodeFactor={explodeFactor}
        onExplodeChange={setExplodeFactor}
        wireframe={wireframe}
        onWireframeToggle={() => setWireframe(!wireframe)}
        showLabels={showLabels}
        onLabelsToggle={() => setShowLabels(!showLabels)}
        onScreenshot={handleScreenshot}
        onResetView={handleResetView}
      />
    </div>
  );
}

// ---------------------------------------------------------------------------
// Utility: flatten parts tree
// ---------------------------------------------------------------------------

function flattenParts(parts: PartNode[]): PartNode[] {
  const result: PartNode[] = [];
  function walk(nodes: PartNode[]) {
    for (const node of nodes) {
      result.push(node);
      walk(node.children);
    }
  }
  walk(parts);
  return result;
}

// ---------------------------------------------------------------------------
// Utility: toggle visibility in tree
// ---------------------------------------------------------------------------

function toggleVisibility(
  parts: PartNode[],
  partId: string,
  visible: boolean,
): PartNode[] {
  return parts.map((part) => {
    if (part.id === partId) {
      return { ...part, visible, children: setAllVisibility(part.children, visible) };
    }
    return { ...part, children: toggleVisibility(part.children, partId, visible) };
  });
}

function setAllVisibility(parts: PartNode[], visible: boolean): PartNode[] {
  return parts.map((p) => ({
    ...p,
    visible,
    children: setAllVisibility(p.children, visible),
  }));
}

export default DigitalTwinPage;

src/pages/digital-twin/components/model-viewer.tsx

Main 3D viewer wrapper using React Three Fiber with OrbitControls, environment lighting, and a grid plane.

import { useRef, useCallback } from "react";
import { Canvas } from "@react-three/fiber";
import {
  OrbitControls,
  Environment,
  Grid,
  PerspectiveCamera,
} from "@react-three/drei";
import { ModelLoader } from "@/pages/digital-twin/components/model-loader";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface ModelViewerProps {
  glbUrl: string;
  selectedPart: string | null;
  hoveredPart: string | null;
  explodeFactor: number;
  wireframe: boolean;
  showLabels: boolean;
  visibleParts: Set<string>;
  onPartClick: (partId: string) => void;
  onPartHover: (partId: string | null) => void;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export function ModelViewer({
  glbUrl,
  selectedPart,
  hoveredPart,
  explodeFactor,
  wireframe,
  showLabels,
  visibleParts,
  onPartClick,
  onPartHover,
}: ModelViewerProps) {
  const controlsRef = useRef<OrbitControlsImpl>(null);

  const handleResetCamera = useCallback(() => {
    if (controlsRef.current) {
      controlsRef.current.reset();
    }
  }, []);

  return (
    <Canvas
      className="h-full w-full"
      shadows
      dpr={[1, 2]}
      gl={{ preserveDrawingBuffer: true }}
      onPointerMissed={() => {
        // Deselect when clicking empty space
        onPartClick("");
      }}
    >
      {/* Camera */}
      <PerspectiveCamera
        makeDefault
        position={[4, 3, 6]}
        fov={50}
        near={0.1}
        far={1000}
      />

      {/* Controls */}
      <OrbitControls
        ref={controlsRef}
        enablePan
        enableZoom
        enableRotate
        dampingFactor={0.1}
        minDistance={1}
        maxDistance={50}
        maxPolarAngle={Math.PI * 0.85}
      />

      {/* Lighting */}
      <Environment preset="studio" />
      <ambientLight intensity={0.4} />
      <directionalLight
        position={[5, 10, 7]}
        intensity={0.8}
        castShadow
        shadow-mapSize={[1024, 1024]}
      />

      {/* Ground grid */}
      <Grid
        args={[20, 20]}
        cellSize={0.5}
        cellThickness={0.5}
        cellColor="#6e6e6e"
        sectionSize={2}
        sectionThickness={1}
        sectionColor="#9d4b4b"
        fadeDistance={25}
        fadeStrength={1}
        followCamera={false}
        position={[0, -0.01, 0]}
      />

      {/* 3D Model */}
      <ModelLoader
        url={glbUrl}
        selectedPart={selectedPart}
        hoveredPart={hoveredPart}
        explodeFactor={explodeFactor}
        wireframe={wireframe}
        visibleParts={visibleParts}
        onPartClick={onPartClick}
        onPartHover={onPartHover}
      />
    </Canvas>
  );
}

src/pages/digital-twin/components/model-loader.tsx

GLB/GLTF loading component that traverses the scene graph, applies highlighting, and implements exploded view.

import { useRef, useEffect, useMemo, useCallback } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import type { ThreeEvent } from "@react-three/fiber";

// ---------------------------------------------------------------------------
// Materials
// ---------------------------------------------------------------------------

const DEFAULT_MATERIAL = new THREE.MeshStandardMaterial({
  color: 0xcccccc,
  roughness: 0.5,
  metalness: 0.3,
});

const SELECTED_MATERIAL = new THREE.MeshStandardMaterial({
  color: 0x3b82f6,
  roughness: 0.3,
  metalness: 0.5,
  emissive: new THREE.Color(0x1d4ed8),
  emissiveIntensity: 0.3,
});

const HOVERED_MATERIAL = new THREE.MeshStandardMaterial({
  color: 0x93c5fd,
  roughness: 0.4,
  metalness: 0.4,
  emissive: new THREE.Color(0x3b82f6),
  emissiveIntensity: 0.15,
});

const WIREFRAME_MATERIAL = new THREE.MeshStandardMaterial({
  color: 0x888888,
  wireframe: true,
});

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface ModelLoaderProps {
  url: string;
  selectedPart: string | null;
  hoveredPart: string | null;
  explodeFactor: number;
  wireframe: boolean;
  visibleParts: Set<string>;
  onPartClick: (partId: string) => void;
  onPartHover: (partId: string | null) => void;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export function ModelLoader({
  url,
  selectedPart,
  hoveredPart,
  explodeFactor,
  wireframe,
  visibleParts,
  onPartClick,
  onPartHover,
}: ModelLoaderProps) {
  const groupRef = useRef<THREE.Group>(null);
  const { scene } = useGLTF(url);

  // Compute the centroid of all meshes for exploded-view calculations
  const centroid = useMemo(() => {
    const center = new THREE.Vector3();
    let count = 0;

    scene.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        child.geometry.computeBoundingBox();
        const meshCenter = new THREE.Vector3();
        child.geometry.boundingBox?.getCenter(meshCenter);
        child.localToWorld(meshCenter);
        center.add(meshCenter);
        count++;
      }
    });

    if (count > 0) center.divideScalar(count);
    return center;
  }, [scene]);

  // Store original positions for exploded view
  const originalPositions = useMemo(() => {
    const positions = new Map<string, THREE.Vector3>();
    scene.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        positions.set(child.uuid, child.position.clone());
      }
    });
    return positions;
  }, [scene]);

  // Apply exploded view, materials, and visibility
  useEffect(() => {
    scene.traverse((child) => {
      if (!(child instanceof THREE.Mesh)) return;

      const partId = child.name || child.uuid;
      const originalPos = originalPositions.get(child.uuid);

      // Visibility
      const isVisible = visibleParts.size === 0 || visibleParts.has(partId);
      child.visible = isVisible;

      // Exploded view: translate outward from centroid
      if (originalPos) {
        const direction = new THREE.Vector3()
          .subVectors(originalPos, centroid)
          .normalize();
        const distance = originalPos.distanceTo(centroid);
        const offset = direction.multiplyScalar(distance * explodeFactor);

        child.position.copy(originalPos).add(offset);
      }

      // Material selection
      if (wireframe) {
        child.material = WIREFRAME_MATERIAL;
      } else if (partId === selectedPart) {
        child.material = SELECTED_MATERIAL;
      } else if (partId === hoveredPart) {
        child.material = HOVERED_MATERIAL;
      } else if (child.userData.originalMaterial) {
        child.material = child.userData.originalMaterial;
      } else {
        // Store the original material on first pass
        if (
          child.material &&
          child.material !== DEFAULT_MATERIAL &&
          child.material !== SELECTED_MATERIAL &&
          child.material !== HOVERED_MATERIAL &&
          child.material !== WIREFRAME_MATERIAL
        ) {
          child.userData.originalMaterial = child.material;
        } else {
          child.material = DEFAULT_MATERIAL;
        }
      }
    });
  }, [
    scene,
    selectedPart,
    hoveredPart,
    explodeFactor,
    wireframe,
    visibleParts,
    centroid,
    originalPositions,
  ]);

  // Event handlers
  const handleClick = useCallback(
    (event: ThreeEvent<MouseEvent>) => {
      event.stopPropagation();
      const mesh = event.object;
      if (mesh instanceof THREE.Mesh) {
        onPartClick(mesh.name || mesh.uuid);
      }
    },
    [onPartClick],
  );

  const handlePointerOver = useCallback(
    (event: ThreeEvent<PointerEvent>) => {
      event.stopPropagation();
      const mesh = event.object;
      if (mesh instanceof THREE.Mesh) {
        document.body.style.cursor = "pointer";
        onPartHover(mesh.name || mesh.uuid);
      }
    },
    [onPartHover],
  );

  const handlePointerOut = useCallback(() => {
    document.body.style.cursor = "auto";
    onPartHover(null);
  }, [onPartHover]);

  return (
    <group ref={groupRef}>
      <primitive
        object={scene}
        onClick={handleClick}
        onPointerOver={handlePointerOver}
        onPointerOut={handlePointerOut}
      />
    </group>
  );
}

// Preload the model for faster initial render
useGLTF.preload("/sample-models/dfc-v2.1.glb");

src/pages/digital-twin/components/component-tree.tsx

Sidebar component tree with hierarchical parts, visibility toggles, search filter, and part selection.

import { useState, useMemo, memo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
  ChevronDown,
  ChevronRight,
  Eye,
  EyeOff,
  Search,
  Layers,
} from "lucide-react";
import type { PartNode } from "@/pages/digital-twin/index";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface ComponentTreeProps {
  parts: PartNode[];
  selectedPartId: string | null;
  onSelect: (partId: string) => void;
  onVisibilityToggle: (partId: string, visible: boolean) => void;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export const ComponentTree = memo(function ComponentTree({
  parts,
  selectedPartId,
  onSelect,
  onVisibilityToggle,
}: ComponentTreeProps) {
  const [searchQuery, setSearchQuery] = useState("");

  // Count all parts
  const totalParts = useMemo(() => {
    let count = 0;
    function walk(nodes: PartNode[]) {
      for (const n of nodes) {
        count++;
        walk(n.children);
      }
    }
    walk(parts);
    return count;
  }, [parts]);

  // Filter parts by search query
  const filteredParts = useMemo(() => {
    if (!searchQuery.trim()) return parts;
    const query = searchQuery.toLowerCase();

    function filterTree(nodes: PartNode[]): PartNode[] {
      const result: PartNode[] = [];
      for (const node of nodes) {
        const matchesSelf = node.name.toLowerCase().includes(query);
        const filteredChildren = filterTree(node.children);

        if (matchesSelf || filteredChildren.length > 0) {
          result.push({
            ...node,
            children: matchesSelf ? node.children : filteredChildren,
          });
        }
      }
      return result;
    }

    return filterTree(parts);
  }, [parts, searchQuery]);

  return (
    <div className="flex h-full flex-col">
      {/* Header */}
      <div className="border-b p-3">
        <div className="flex items-center gap-2">
          <Layers className="h-4 w-4 text-muted-foreground" />
          <h3 className="text-sm font-semibold">Components</h3>
          <span className="ml-auto text-xs text-muted-foreground">
            {totalParts} parts
          </span>
        </div>

        {/* Search */}
        <div className="relative mt-2">
          <Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
          <Input
            placeholder="Filter parts..."
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="h-8 pl-8 text-xs"
          />
        </div>
      </div>

      {/* Tree */}
      <ScrollArea className="flex-1">
        <div className="p-2">
          {filteredParts.length === 0 ? (
            <p className="p-3 text-center text-xs text-muted-foreground">
              No parts match "{searchQuery}"
            </p>
          ) : (
            filteredParts.map((part) => (
              <TreeNode
                key={part.id}
                node={part}
                depth={0}
                selectedPartId={selectedPartId}
                onSelect={onSelect}
                onVisibilityToggle={onVisibilityToggle}
              />
            ))
          )}
        </div>
      </ScrollArea>
    </div>
  );
});

// ---------------------------------------------------------------------------
// TreeNode sub-component
// ---------------------------------------------------------------------------

interface TreeNodeProps {
  node: PartNode;
  depth: number;
  selectedPartId: string | null;
  onSelect: (partId: string) => void;
  onVisibilityToggle: (partId: string, visible: boolean) => void;
}

function TreeNode({
  node,
  depth,
  selectedPartId,
  onSelect,
  onVisibilityToggle,
}: TreeNodeProps) {
  const [expanded, setExpanded] = useState(true);
  const hasChildren = node.children.length > 0;
  const isSelected = node.id === selectedPartId;

  const handleToggleExpand = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      setExpanded((prev) => !prev);
    },
    [],
  );

  const handleVisibilityClick = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      onVisibilityToggle(node.id, !node.visible);
    },
    [node.id, node.visible, onVisibilityToggle],
  );

  return (
    <div>
      <div
        className={cn(
          "group flex items-center gap-1 rounded-md px-2 py-1 text-xs cursor-pointer transition-colors",
          isSelected
            ? "bg-primary/10 text-primary font-medium"
            : "hover:bg-muted/50",
          !node.visible && "opacity-50",
        )}
        style={{ paddingLeft: `${depth * 16 + 8}px` }}
        onClick={() => onSelect(node.id)}
      >
        {/* Expand/collapse chevron */}
        {hasChildren ? (
          <button
            onClick={handleToggleExpand}
            className="shrink-0 rounded p-0.5 hover:bg-muted"
          >
            {expanded ? (
              <ChevronDown className="h-3 w-3" />
            ) : (
              <ChevronRight className="h-3 w-3" />
            )}
          </button>
        ) : (
          <span className="w-4 shrink-0" />
        )}

        {/* Part name */}
        <span className="flex-1 truncate">{node.name}</span>

        {/* Visibility toggle */}
        <button
          onClick={handleVisibilityClick}
          className="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
          title={node.visible ? "Hide part" : "Show part"}
        >
          {node.visible ? (
            <Eye className="h-3 w-3 text-muted-foreground" />
          ) : (
            <EyeOff className="h-3 w-3 text-muted-foreground" />
          )}
        </button>
      </div>

      {/* Children */}
      {hasChildren && expanded && (
        <div>
          {node.children.map((child) => (
            <TreeNode
              key={child.id}
              node={child}
              depth={depth + 1}
              selectedPartId={selectedPartId}
              onSelect={onSelect}
              onVisibilityToggle={onVisibilityToggle}
            />
          ))}
        </div>
      )}
    </div>
  );
}

src/pages/digital-twin/components/annotation-overlay.tsx

BOM annotation panel for selected parts, showing part details, pricing, stock, and lifecycle status.

import { memo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
  X,
  Package,
  DollarSign,
  Warehouse,
  Activity,
  ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { SelectedPartInfo } from "@/pages/digital-twin/index";

// ---------------------------------------------------------------------------
// Lifecycle color mapping
// ---------------------------------------------------------------------------

const LIFECYCLE_COLORS: Record<string, string> = {
  active: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
  nrnd: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400",
  eol: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
  obsolete: "bg-red-200 text-red-900 dark:bg-red-900/50 dark:text-red-300 line-through",
  unknown: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
};

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface AnnotationOverlayProps {
  part: SelectedPartInfo;
  onClose: () => void;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export const AnnotationOverlay = memo(function AnnotationOverlay({
  part,
  onClose,
}: AnnotationOverlayProps) {
  const lifecycleColor =
    LIFECYCLE_COLORS[part.lifecycle ?? "unknown"] ?? LIFECYCLE_COLORS.unknown;

  return (
    <div className="flex h-full flex-col">
      {/* Header */}
      <div className="flex items-center justify-between border-b p-3">
        <h3 className="text-sm font-semibold">Part Details</h3>
        <Button
          variant="ghost"
          size="icon"
          className="h-6 w-6"
          onClick={onClose}
        >
          <X className="h-3.5 w-3.5" />
        </Button>
      </div>

      <ScrollArea className="flex-1">
        <div className="space-y-4 p-4">
          {/* Part name */}
          <div>
            <h4 className="text-base font-semibold">{part.name}</h4>
            <p className="text-xs text-muted-foreground">ID: {part.id}</p>
          </div>

          {/* Details grid */}
          <div className="space-y-3">
            {/* MPN */}
            {part.mpn && (
              <DetailRow
                icon={<Package className="h-3.5 w-3.5" />}
                label="MPN"
                value={
                  <span className="font-mono text-xs font-medium">
                    {part.mpn}
                  </span>
                }
              />
            )}

            {/* Manufacturer */}
            {part.manufacturer && (
              <DetailRow
                icon={<Package className="h-3.5 w-3.5" />}
                label="Manufacturer"
                value={part.manufacturer}
              />
            )}

            {/* Price */}
            {part.price !== undefined && (
              <DetailRow
                icon={<DollarSign className="h-3.5 w-3.5" />}
                label="Unit Price"
                value={
                  <span className="font-medium tabular-nums">
                    ${part.price.toFixed(2)}
                  </span>
                }
              />
            )}

            {/* Stock */}
            {part.stock !== undefined && (
              <DetailRow
                icon={<Warehouse className="h-3.5 w-3.5" />}
                label="Stock"
                value={
                  <span
                    className={cn(
                      "font-medium tabular-nums",
                      part.stock === 0 && "text-red-600 dark:text-red-400",
                      part.stock > 0 &&
                        part.stock < 100 &&
                        "text-amber-600 dark:text-amber-400",
                    )}
                  >
                    {part.stock.toLocaleString()} units
                  </span>
                }
              />
            )}

            {/* Lifecycle */}
            {part.lifecycle && (
              <DetailRow
                icon={<Activity className="h-3.5 w-3.5" />}
                label="Lifecycle"
                value={
                  <Badge
                    className={cn("text-[10px] capitalize", lifecycleColor)}
                  >
                    {part.lifecycle}
                  </Badge>
                }
              />
            )}
          </div>

          {/* Link to BOM */}
          <div className="border-t pt-3">
            <Button
              variant="outline"
              size="sm"
              className="w-full text-xs"
              asChild
            >
              <a href="/bom">
                <ExternalLink className="mr-1.5 h-3 w-3" />
                View in BOM Table
              </a>
            </Button>
          </div>
        </div>
      </ScrollArea>
    </div>
  );
});

// ---------------------------------------------------------------------------
// Internal sub-component
// ---------------------------------------------------------------------------

interface DetailRowProps {
  icon: React.ReactNode;
  label: string;
  value: React.ReactNode;
}

function DetailRow({ icon, label, value }: DetailRowProps) {
  return (
    <div className="flex items-start gap-2 text-sm">
      <span className="mt-0.5 text-muted-foreground">{icon}</span>
      <div className="flex-1">
        <p className="text-xs text-muted-foreground">{label}</p>
        <div className="mt-0.5">{value}</div>
      </div>
    </div>
  );
}

src/pages/digital-twin/components/exploded-view-controls.tsx

Bottom toolbar with explode slider, wireframe toggle, screenshot export, reset view, fit-to-view, and part labels toggle.

import { memo } from "react";
import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import {
  Maximize,
  RotateCcw,
  Camera,
  Box,
  Tag,
  Expand,
} from "lucide-react";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface ExplodedViewControlsProps {
  explodeFactor: number;
  onExplodeChange: (value: number) => void;
  wireframe: boolean;
  onWireframeToggle: () => void;
  showLabels: boolean;
  onLabelsToggle: () => void;
  onScreenshot: () => void;
  onResetView: () => void;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export const ExplodedViewControls = memo(function ExplodedViewControls({
  explodeFactor,
  onExplodeChange,
  wireframe,
  onWireframeToggle,
  showLabels,
  onLabelsToggle,
  onScreenshot,
  onResetView,
}: ExplodedViewControlsProps) {
  return (
    <TooltipProvider delayDuration={200}>
      <div className="flex items-center gap-3 border-t bg-card px-4 py-2">
        {/* Explode slider */}
        <div className="flex items-center gap-2">
          <Tooltip>
            <TooltipTrigger asChild>
              <span className="flex items-center gap-1 text-xs text-muted-foreground">
                <Expand className="h-3.5 w-3.5" />
                Explode
              </span>
            </TooltipTrigger>
            <TooltipContent>
              Adjust the exploded view factor (0% assembled to 100% exploded)
            </TooltipContent>
          </Tooltip>

          <input
            type="range"
            min={0}
            max={1}
            step={0.01}
            value={explodeFactor}
            onChange={(e) => onExplodeChange(parseFloat(e.target.value))}
            className="h-1.5 w-36 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
            aria-label="Explode factor"
          />

          <span className="w-10 text-right font-mono text-xs tabular-nums text-muted-foreground">
            {Math.round(explodeFactor * 100)}%
          </span>
        </div>

        {/* Separator */}
        <div className="h-6 w-px bg-border" />

        {/* Wireframe toggle */}
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant={wireframe ? "default" : "ghost"}
              size="icon"
              className="h-8 w-8"
              onClick={onWireframeToggle}
              aria-label="Toggle wireframe"
            >
              <Box className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Toggle wireframe mode</TooltipContent>
        </Tooltip>

        {/* Part labels toggle */}
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant={showLabels ? "default" : "ghost"}
              size="icon"
              className="h-8 w-8"
              onClick={onLabelsToggle}
              aria-label="Toggle part labels"
            >
              <Tag className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Toggle part labels</TooltipContent>
        </Tooltip>

        {/* Separator */}
        <div className="h-6 w-px bg-border" />

        {/* Screenshot */}
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant="ghost"
              size="icon"
              className="h-8 w-8"
              onClick={onScreenshot}
              aria-label="Take screenshot"
            >
              <Camera className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Export screenshot (PNG)</TooltipContent>
        </Tooltip>

        {/* Fit to view */}
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant="ghost"
              size="icon"
              className="h-8 w-8"
              onClick={onResetView}
              aria-label="Fit to view"
            >
              <Maximize className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Fit model to view</TooltipContent>
        </Tooltip>

        {/* Reset view */}
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant="ghost"
              size="icon"
              className="h-8 w-8"
              onClick={onResetView}
              aria-label="Reset view"
            >
              <RotateCcw className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Reset camera and all controls</TooltipContent>
        </Tooltip>
      </div>
    </TooltipProvider>
  );
});

src/pages/digital-twin/components/step-converter-worker.ts

Web Worker for STEP to GLB conversion using opencascade.js WASM.

/**
 * step-converter-worker.ts
 *
 * Web Worker that converts STEP/STP CAD files to glTF Binary (GLB) format
 * using the opencascade.js WebAssembly module.
 *
 * Architecture:
 * - The main thread sends a message with the STEP file as an ArrayBuffer.
 * - This worker loads the opencascade.js WASM module (lazy, cached).
 * - The STEP geometry is tessellated and converted to a GLB binary.
 * - The resulting GLB ArrayBuffer is sent back to the main thread.
 *
 * For files exceeding 20MB, the main thread should fall back to server-side
 * conversion via POST /api/v1/artifacts/:id/gltf rather than running the
 * conversion in-browser.
 *
 * In demo/mock mode, this worker is not used — the app loads pre-converted
 * GLB files directly from public/sample-models/.
 */

// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------

export interface StepConvertRequest {
  type: "convert";
  /** Raw STEP file contents as an ArrayBuffer */
  data: ArrayBuffer;
  /** Optional artifact ID for server fallback reference */
  artifactId?: string;
}

export interface StepConvertResult {
  type: "result";
  /** Converted GLB binary */
  glb: ArrayBuffer;
}

export interface StepConvertError {
  type: "error";
  message: string;
}

export type WorkerOutgoingMessage = StepConvertResult | StepConvertError;

// ---------------------------------------------------------------------------
// OpenCascade WASM loader (lazy-initialized)
// ---------------------------------------------------------------------------

/**
 * The opencascade.js module is loaded lazily on first conversion request.
 * In a production build, the WASM binary should be served from the same
 * origin and cached by the service worker.
 *
 * @see https://github.com/nicholasgasior/opencascade.js
 */
let ocInstance: unknown | null = null;

async function getOpenCascade(): Promise<unknown> {
  if (ocInstance) return ocInstance;

  // Dynamic import — the opencascade.js package exposes an init function
  // that returns a promise resolving to the OC API object.
  //
  // NOTE: This import path assumes opencascade.js is installed as a
  // dependency. The WASM file is typically ~25MB and should be served
  // with appropriate caching headers.
  const initOpenCascade = (await import("opencascade.js")).default;
  ocInstance = await initOpenCascade({
    locateFile: (file: string) => `/wasm/${file}`,
  });

  return ocInstance;
}

// ---------------------------------------------------------------------------
// Conversion pipeline
// ---------------------------------------------------------------------------

async function convertStepToGlb(stepBuffer: ArrayBuffer): Promise<ArrayBuffer> {
  const oc = (await getOpenCascade()) as Record<string, unknown>;

  // 1. Write the STEP data to the OpenCascade virtual filesystem
  const uint8 = new Uint8Array(stepBuffer);
  const fileName = "input.step";

  // @ts-expect-error — opencascade.js FS API
  oc.FS.writeFile(fileName, uint8);

  // 2. Read the STEP file using the STEP reader
  // @ts-expect-error — opencascade.js API
  const reader = new oc.STEPControl_Reader();
  // @ts-expect-error — opencascade.js API
  reader.ReadFile(fileName);
  reader.TransferRoots();

  // 3. Get the resulting shape
  const shape = reader.OneShape();

  // 4. Tessellate the shape (mesh it for visualization)
  // @ts-expect-error — opencascade.js API
  new oc.BRepMesh_IncrementalMesh(shape, 0.1, false, 0.5, true);

  // 5. Convert tessellated shape to GLB using the built-in exporter
  // @ts-expect-error — opencascade.js API
  const writer = new oc.RWGltf_CafWriter(
    // @ts-expect-error — opencascade.js API
    new oc.TCollection_AsciiString("output.glb"),
    true, // binary format
  );

  // 6. Write and read back the GLB file
  // @ts-expect-error — opencascade.js FS API
  const glbData: Uint8Array = oc.FS.readFile("output.glb");

  // 7. Clean up the virtual filesystem
  // @ts-expect-error — opencascade.js FS API
  oc.FS.unlink(fileName);
  // @ts-expect-error — opencascade.js FS API
  oc.FS.unlink("output.glb");

  // Transfer ownership of the buffer
  return glbData.buffer.slice(
    glbData.byteOffset,
    glbData.byteOffset + glbData.byteLength,
  );
}

// ---------------------------------------------------------------------------
// Worker message handler
// ---------------------------------------------------------------------------

self.addEventListener("message", async (event: MessageEvent<StepConvertRequest>) => {
  const { type, data } = event.data;

  if (type !== "convert") {
    const error: StepConvertError = {
      type: "error",
      message: `Unknown message type: ${type}`,
    };
    self.postMessage(error);
    return;
  }

  // Size guard: files over 20MB should use server-side conversion
  const MAX_CLIENT_SIZE = 20 * 1024 * 1024; // 20MB
  if (data.byteLength > MAX_CLIENT_SIZE) {
    const error: StepConvertError = {
      type: "error",
      message: `File size ${(data.byteLength / 1024 / 1024).toFixed(1)}MB exceeds client-side limit of 20MB. Use server-side conversion via POST /api/v1/artifacts/:id/gltf instead.`,
    };
    self.postMessage(error);
    return;
  }

  try {
    const glb = await convertStepToGlb(data);
    const result: StepConvertResult = { type: "result", glb };
    // Transfer the ArrayBuffer to avoid copying
    self.postMessage(result, [glb]);
  } catch (err) {
    const error: StepConvertError = {
      type: "error",
      message: err instanceof Error ? err.message : "STEP conversion failed",
    };
    self.postMessage(error);
  }
});

// Signal that the worker is ready
self.postMessage({ type: "ready" });

Live State Overlay (L3 — Future)

In Phase 3, when a DeviceInstance is selected in the 3D viewer, the viewer subscribes to its telemetry WebSocket and overlays live device state onto the 3D model. This enables real-time visualization of sensor data, thermal maps, stress indicators, and anomaly status directly on the hardware model.

WebSocket Subscription

// Future: WebSocket subscription for live device state
interface LiveStateOverlay {
  deviceId: string;
  meshOverlays: {
    meshName: string;
    colorMap: "thermal" | "stress" | "vibration";
    values: number[];  // Per-vertex or per-face values from telemetry/simulation
  }[];
  annotations: {
    position: [number, number, number];
    label: string;
    value: string;
    status: "nominal" | "warning" | "critical";
  }[];
}

Integration Architecture

DeviceInstance selected in 3D viewer
     ↓
Dashboard subscribes to WebSocket: /ws/devices/{deviceId}/telemetry
     ↓
Telemetry Router pushes aggregated readings
     ↓
3D Viewer maps sensor values to mesh overlays:
  - Thermal sensors → heat map on enclosure/PCB meshes
  - Vibration sensors → stress visualization on mechanical parts
  - Power readings → annotated labels on power rail components
     ↓
Anomaly detection triggers visual alerts (mesh color change + annotation)

Use Cases

Scenario Overlay Type Data Source
Board thermal monitoring Thermal color map Temperature sensors via TSDB
Power rail live readings Annotated labels Power telemetry via WebSocket
Vibration analysis Stress visualization Accelerometer data
Anomaly alert Mesh highlight + badge Anomaly detector threshold breach
Simulation comparison Side-by-side overlay SimulationRun results vs live data

See Digital Twin Evolution for the full synchronization and simulation architecture.