fix(3d-viewer): downgrade three.js/fiber/drei dependencies to stable versions and restore full viewer code
- Fixes library crash on startup - Restores interactive facility map
This commit is contained in:
parent
8bd23cdb6f
commit
d75be99748
2 changed files with 296 additions and 19 deletions
|
|
@ -16,8 +16,8 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "9.107.2",
|
||||||
"@react-three/fiber": "^9.4.2",
|
"@react-three/fiber": "8.16.8",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
"react-konva": "^18.2.10",
|
"react-konva": "^18.2.10",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.182.0",
|
"three": "0.165.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,303 @@
|
||||||
|
import { useEffect, useState, Suspense, useMemo, Component, ReactNode } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
|
import { OrbitControls, Text, Instances, Instance, Html } from '@react-three/drei';
|
||||||
|
import { layoutApi, Floor3DData } from '../lib/layoutApi';
|
||||||
|
import { Loader2, ArrowLeft } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
|
// Colors
|
||||||
|
const COLORS = {
|
||||||
|
VEG: '#4ade80', // green-400
|
||||||
|
FLOWER: '#a855f7', // purple-500
|
||||||
|
DRY: '#f59e0b', // amber-500
|
||||||
|
CURE: '#78716c', // stone-500
|
||||||
|
EMPTY_SLOT: '#374151', // gray-700
|
||||||
|
ROOM_FLOOR: '#1f2937', // gray-800
|
||||||
|
ROOM_WALL: '#374151', // gray-700
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define State interface
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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; // Or return a subset
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Active Plants */}
|
||||||
|
{plants.length > 0 && (
|
||||||
|
<Instances
|
||||||
|
range={plants.length}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const index = e.instanceId;
|
||||||
|
if (index !== undefined && plants[index]) {
|
||||||
|
onPlantClick(plants[index]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.15, 16, 16]} />
|
||||||
|
<meshStandardMaterial />
|
||||||
|
{plants.map((pos, i) => (
|
||||||
|
<Instance
|
||||||
|
key={pos.id || i}
|
||||||
|
position={[pos.x, pos.y, pos.z]}
|
||||||
|
color={pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER :
|
||||||
|
pos.plant?.stage === 'DRYING' ? COLORS.DRY :
|
||||||
|
pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Instances>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 || i}
|
||||||
|
position={[pos.x, pos.y, pos.z]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Instances>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPlant: (p: any) => void }) {
|
||||||
|
// Process data into flat list of objects for rendering
|
||||||
|
const roomMeshes = useMemo(() => {
|
||||||
|
return data.rooms.map(room => {
|
||||||
|
// Room creates a floor area
|
||||||
|
return (
|
||||||
|
<group key={room.id} position={[room.posX, 0, room.posY]}>
|
||||||
|
{/* Floor Label */}
|
||||||
|
<Text
|
||||||
|
position={[room.width / 2, 0.1, room.height / 2]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={0.5}
|
||||||
|
color="white"
|
||||||
|
fillOpacity={0.5}
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Floor Plane */}
|
||||||
|
<mesh
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
position={[room.width / 2, 0, room.height / 2]}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[room.width, room.height]} />
|
||||||
|
<meshStandardMaterial color={COLORS.ROOM_FLOOR} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Render sections and positions */}
|
||||||
|
{room.sections.map(section => {
|
||||||
|
// Calculate positions for this section
|
||||||
|
const positions = section.positions.map(pos => {
|
||||||
|
// Simple grid layout logic for position coordinates relative to section
|
||||||
|
// Assuming typical rack dimensions
|
||||||
|
const spacing = 0.5;
|
||||||
|
const x = section.posX + (pos.column * spacing);
|
||||||
|
const z = section.posY + (pos.row * spacing); // Z is depth/row
|
||||||
|
const y = 0.5 + (pos.tier * 0.5); // Y is height/tier
|
||||||
|
|
||||||
|
return { ...pos, x, y, z };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={section.id}>
|
||||||
|
{/* Section Label */}
|
||||||
|
<Text
|
||||||
|
position={[section.posX, 2.5, section.posY]}
|
||||||
|
fontSize={0.3}
|
||||||
|
color="#9ca3af"
|
||||||
|
>
|
||||||
|
{section.code}
|
||||||
|
</Text>
|
||||||
|
<PlantInstances positions={positions} onPlantClick={onSelectPlant} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [data, onSelectPlant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.7} />
|
||||||
|
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||||
|
<directionalLight position={[-10, 20, -10]} intensity={0.5} />
|
||||||
|
|
||||||
|
<group position={[-data.floor.width / 2, 0, -data.floor.height / 2]}>
|
||||||
|
{roomMeshes}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2.2} />
|
||||||
|
<gridHelper args={[100, 100, 0x444444, 0x222222]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Facility3DViewerPage() {
|
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 (
|
return (
|
||||||
<div className="h-screen w-full bg-gray-950 text-white relative">
|
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden">
|
||||||
<div className="absolute top-4 left-4 z-10">
|
{/* Header Overlay */}
|
||||||
<Link to="/metrc" className="btn btn-ghost btn-sm text-white flex gap-2">
|
<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
|
<ArrowLeft size={16} /> Back
|
||||||
</Link>
|
</Link>
|
||||||
<div className="mt-2 bg-black/50 p-2 rounded">
|
<div>
|
||||||
<p>Debug Mode: Red Cube</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Canvas>
|
{/* Legend */}
|
||||||
<ambientLight intensity={0.5} />
|
<div className="flex gap-4 text-xs bg-black/50 p-2 rounded-lg backdrop-blur pointer-events-auto">
|
||||||
<directionalLight position={[10, 10, 5]} />
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#4ade80]"></div> Veg</div>
|
||||||
<mesh position={[0, 0, 0]}>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#a855f7]"></div> Flower</div>
|
||||||
<boxGeometry />
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> Dry</div>
|
||||||
<meshStandardMaterial color="red" />
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#374151]"></div> Empty</div>
|
||||||
</mesh>
|
</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>
|
||||||
|
{status.includes('Error') && (
|
||||||
|
<button onClick={() => window.location.reload()} className="btn btn-sm btn-outline mt-4">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue