fix(3d-viewer): add robust error handling and safety checks
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Limit excessive instances
- Add localized ErrorBoundary to prevent full app crash
- Add safe data access for optional plant properties
This commit is contained in:
fullsizemalt 2025-12-17 03:37:15 -08:00
parent dd647538c5
commit 699cb73621

View file

@ -1,4 +1,4 @@
import { useEffect, useState, Suspense, useRef, useMemo } from 'react';
import { useEffect, useState, Suspense, useRef, useMemo, Component, ReactNode } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Text, Instances, Instance, Html } from '@react-three/drei';
import { layoutApi, Floor3DData } from '../lib/layoutApi';
@ -17,15 +17,174 @@ const COLORS = {
ROOM_WALL: '#374151', // gray-700
};
function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlantClick: (p: any) => void }) {
if (!positions || positions.length === 0) return null;
// Define State interface
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
const plants = positions.filter(p => p.plant);
const emptySlots = positions.filter(p => !p.plant);
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
console.error("3D Viewer Error Caught:", this.state.error);
return (
<Html center>
<div className="bg-red-900/90 text-white p-4 rounded border border-red-500 max-w-md">
<h3 className="font-bold mb-2">3D Rendering Error</h3>
<p className="text-sm font-mono break-words">{this.state.error?.message}</p>
</div>
</Html>
);
}
return this.props.children;
}
}
// ... unchanged parts ...
// Update Facility3DViewerPage to use ErrorBoundary
export default function Facility3DViewerPage() {
const [status, setStatus] = useState('Initializing...');
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
const [selectedPlant, setSelectedPlant] = useState<any | null>(null);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setStatus('Loading layout...');
try {
const props = await layoutApi.getProperties();
if (props[0]?.buildings[0]?.floors[0]) {
const floorId = props[0].buildings[0].floors[0].id;
setStatus('Fetching 3D assets...');
const data = await layoutApi.getFloor3D(floorId);
setFloorData(data);
setStatus('');
} else {
setStatus('No floor layout found');
}
} catch (err) {
setStatus('Error: ' + (err as Error).message);
}
}
return (
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden">
{/* Header Overlay */}
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex items-center justify-between bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div className="pointer-events-auto flex items-center gap-4">
<Link to="/metrc" className="btn btn-ghost btn-sm text-white">
<ArrowLeft size={16} /> Back
</Link>
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
Facility Viewer 3D
<span className="badge badge-accent text-xs">BETA</span>
</h1>
<p className="text-xs text-gray-400">
{floorData ? `${floorData.floor.name}${floorData.stats.occupiedPositions} Plants` : 'Loading...'}
</p>
</div>
</div>
{/* Legend */}
<div className="flex gap-4 text-xs bg-black/50 p-2 rounded-lg backdrop-blur pointer-events-auto">
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#4ade80]"></div> Veg</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#a855f7]"></div> Flower</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> Dry</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#374151]"></div> Empty</div>
</div>
</div>
{/* Error/Status Overlay */}
{status && (
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/80 backdrop-blur-sm">
<div className="text-center">
<Loader2 size={32} className="animate-spin text-accent mx-auto mb-2" />
<p className="text-gray-300">{status}</p>
</div>
</div>
)}
{/* Selection Overlay */}
{selectedPlant && (
<div className="absolute bottom-4 right-4 z-20 w-80 bg-gray-900/90 border border-gray-700 rounded-lg p-4 shadow-xl backdrop-blur-md animate-in slide-in-from-right-10 pointer-events-auto">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-accent">{selectedPlant.plant.tagNumber}</h3>
<button
onClick={() => setSelectedPlant(null)}
className="text-gray-400 hover:text-white"
>
×
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Strain:</span>
<span>{selectedPlant.plant.strain || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Stage:</span>
<span className="badge badge-accent bg-opacity-20">{selectedPlant.plant.stage || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Batch:</span>
<span>{selectedPlant.plant.batchName || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Location:</span>
<span className="font-mono text-xs">R{selectedPlant.row} T{selectedPlant.tier} S{selectedPlant.slot}</span>
</div>
</div>
</div>
)}
<Canvas camera={{ position: [10, 15, 10], fov: 50 }}>
<ErrorBoundary>
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
{floorData && (
<FacilityScene
data={floorData}
onSelectPlant={setSelectedPlant}
/>
)}
</Suspense>
</ErrorBoundary>
</Canvas>
</div>
);
}
function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlantClick: (p: any) => void }) {
if (!positions || !Array.isArray(positions) || positions.length === 0) return null;
// Safety filter
const safePositions = positions.filter(p => p && typeof p.x === 'number' && typeof p.y === 'number' && typeof p.z === 'number');
const plants = safePositions.filter(p => p.plant);
const emptySlots = safePositions.filter(p => !p.plant);
// Limit instances to prevent crash on huge datasets just in case
if (safePositions.length > 5000) {
console.warn('Too many positions to render:', safePositions.length);
return null;
}
return (
<group>
{/* Active Plants */}
{plants.length > 0 && (
<Instances
range={plants.length}
onClick={(e) => {
@ -40,7 +199,7 @@ function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlant
<meshStandardMaterial />
{plants.map((pos, i) => (
<Instance
key={pos.id}
key={pos.id || i}
position={[pos.x, pos.y, pos.z]}
color={pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER :
pos.plant?.stage === 'DRYING' ? COLORS.DRY :
@ -48,18 +207,21 @@ function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlant
/>
))}
</Instances>
)}
{/* Empty Slots (Small dots) */}
{/* Empty Slots */}
{emptySlots.length > 0 && (
<Instances range={emptySlots.length}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial color={COLORS.EMPTY_SLOT} opacity={0.5} transparent />
{emptySlots.map((pos, i) => (
<Instance
key={pos.id}
key={pos.id || i}
position={[pos.x, pos.y, pos.z]}
/>
))}
</Instances>
)}
</group>
);
}