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
src/pages/digital-twin/index.tsxsrc/pages/digital-twin/components/model-viewer.tsxsrc/pages/digital-twin/components/model-loader.tsxsrc/pages/digital-twin/components/component-tree.tsxsrc/pages/digital-twin/components/annotation-overlay.tsxsrc/pages/digital-twin/components/exploded-view-controls.tsxsrc/pages/digital-twin/components/step-converter-worker.ts- Live State Overlay (L3 — Future)
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.