diff --git a/frontend/src/components/facility3d/FacilityScene.tsx b/frontend/src/components/facility3d/FacilityScene.tsx new file mode 100644 index 0000000..1646528 --- /dev/null +++ b/frontend/src/components/facility3d/FacilityScene.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { CameraControls, Environment, ContactShadows } from '@react-three/drei'; +import type { Floor3DData, Room3D } from '../../lib/layoutApi'; +import { RoomObject } from './RoomObject'; +import { PlantPosition, VisMode } from './types'; + +interface FacilitySceneProps { + data: Floor3DData; + visMode: VisMode; + targetView: { x: number; y: number; z: number; zoom?: boolean } | null; + onPlantClick: (plant: PlantPosition) => void; + onControlsReady: (controls: CameraControls) => void; +} + +export function FacilityScene({ + data, + visMode, + targetView, + onPlantClick, + onControlsReady, +}: FacilitySceneProps) { + const controlsRef = useRef(null); + const [keysPressed, setKeysPressed] = useState>({}); + + useEffect(() => { + if (controlsRef.current) onControlsReady(controlsRef.current); + }, [controlsRef.current, onControlsReady]); + + useEffect(() => { + if (targetView && controlsRef.current) { + const { x, z, zoom } = targetView; + const dist = zoom ? 12 : 35; + const height = zoom ? 10 : 30; + controlsRef.current.setLookAt(x + dist, height, z + dist, x, 0, z, true); + } + }, [targetView]); + + useEffect(() => { + const handleDown = (e: KeyboardEvent) => { + if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { + e.preventDefault(); + setKeysPressed(k => ({ ...k, [e.code]: true })); + } + }; + const handleUp = (e: KeyboardEvent) => { + setKeysPressed(k => ({ ...k, [e.code]: false })); + }; + + window.addEventListener('keydown', handleDown); + window.addEventListener('keyup', handleUp); + return () => { + window.removeEventListener('keydown', handleDown); + window.removeEventListener('keyup', handleUp); + }; + }, []); + + useFrame((_, delta) => { + if (!controlsRef.current) return; + const speed = 18 * delta; + if (keysPressed['KeyW'] || keysPressed['ArrowUp']) controlsRef.current.forward(speed, true); + if (keysPressed['KeyS'] || keysPressed['ArrowDown']) controlsRef.current.forward(-speed, true); + if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) controlsRef.current.truck(-speed, 0, true); + if (keysPressed['KeyD'] || keysPressed['ArrowRight']) controlsRef.current.truck(speed, 0, true); + }); + + return ( + <> + + + + + + {data.rooms.map((room: Room3D) => ( + + ))} + + + + + + + ); +} diff --git a/frontend/src/components/facility3d/PlantSystem.tsx b/frontend/src/components/facility3d/PlantSystem.tsx new file mode 100644 index 0000000..165fa56 --- /dev/null +++ b/frontend/src/components/facility3d/PlantSystem.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { Instances, Instance } from '@react-three/drei'; +import * as THREE from 'three'; +import { PlantPosition, VisMode, COLORS } from './types'; + +// Mock helpers +const getMockPlantHealth = (_plantId: string) => { + const r = Math.random(); + if (r > 0.95) return 'CRITICAL'; + if (r > 0.85) return 'WARNING'; + return 'GOOD'; +}; + +const lerpColor = (c1: string, c2: string, t: number) => { + const c1c = new THREE.Color(c1); + const c2c = new THREE.Color(c2); + return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString(); +}; + +interface PlantSystemProps { + positions: PlantPosition[]; + visMode: VisMode; + onPlantClick: (plant: PlantPosition) => void; +} + +export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemProps) { + if (!positions || positions.length === 0) return null; + + const plants = positions.filter(p => p.plant); + const emptySlots = positions.filter(p => !p.plant); + + if (plants.length > 5000) return null; + + const plantColors = useMemo(() => { + return plants.map(pos => { + switch (visMode) { + case 'STANDARD': + return pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER : + pos.plant?.stage === 'DRYING' ? COLORS.DRY : + pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG; + case 'HEALTH': + const status = getMockPlantHealth(pos.plant!.id); + return status === 'CRITICAL' ? COLORS.HEALTH_CRIT : + status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD; + case 'YIELD': + return lerpColor('#86efac', '#14532d', Math.random()); + default: + return '#555'; + } + }); + }, [plants, visMode]); + + return ( + + {plants.length > 0 && ( + { + e.stopPropagation(); + const index = e.instanceId; + if (index !== undefined && plants[index]) { + onPlantClick(plants[index]); + } + }} + > + + + {plants.map((pos, i) => ( + + ))} + + )} + + {emptySlots.length > 0 && visMode === 'STANDARD' && ( + + + + {emptySlots.map((pos, i) => ( + + ))} + + )} + + ); +} diff --git a/frontend/src/components/facility3d/RoomObject.tsx b/frontend/src/components/facility3d/RoomObject.tsx new file mode 100644 index 0000000..309a5a8 --- /dev/null +++ b/frontend/src/components/facility3d/RoomObject.tsx @@ -0,0 +1,98 @@ +import { Text } from '@react-three/drei'; +import * as THREE from 'three'; +import type { Room3D } from '../../lib/layoutApi'; +import { PlantPosition, VisMode, COLORS } from './types'; +import { SmartRack } from './SmartRack'; + +// Mock environment data +const getMockRoomEnv = (roomName: string) => { + const hash = roomName.split('').reduce((a, b) => a + b.charCodeAt(0), 0); + return { + temp: 68 + (hash % 15), + humidity: 40 + (hash % 40), + }; +}; + +const lerpColor = (c1: string, c2: string, t: number) => { + const c1c = new THREE.Color(c1); + const c2c = new THREE.Color(c2); + return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString(); +}; + +interface RoomObjectProps { + room: Room3D; + visMode: VisMode; + onPlantClick: (plant: PlantPosition) => void; +} + +export function RoomObject({ room, visMode, onPlantClick }: RoomObjectProps) { + const env = getMockRoomEnv(room.name); + + let floorColor: string = COLORS.ROOM_FLOOR; + if (visMode === 'TEMP') { + const t = (env.temp - 65) / 20; + floorColor = t < 0.5 + ? lerpColor(COLORS.TEMP_LOW, COLORS.TEMP_OPTIMAL, t * 2) + : lerpColor(COLORS.TEMP_OPTIMAL, COLORS.TEMP_HIGH, (t - 0.5) * 2); + } else if (visMode === 'HUMIDITY') { + const t = (env.humidity - 40) / 40; + floorColor = t < 0.5 + ? lerpColor(COLORS.HUMIDITY_DRY, COLORS.HUMIDITY_OPTIMAL, t * 2) + : lerpColor(COLORS.HUMIDITY_OPTIMAL, COLORS.HUMIDITY_WET, (t - 0.5) * 2); + } + + return ( + + + {room.name} + + + {(visMode === 'TEMP' || visMode === 'HUMIDITY') && ( + + {visMode === 'TEMP' ? `${env.temp}°F` : `${env.humidity}%`} + + )} + + + + + + + + + + + + {room.sections.map(section => ( + + ))} + + ); +} diff --git a/frontend/src/components/facility3d/SmartRack.tsx b/frontend/src/components/facility3d/SmartRack.tsx new file mode 100644 index 0000000..fb72b02 --- /dev/null +++ b/frontend/src/components/facility3d/SmartRack.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { Text } from '@react-three/drei'; +import * as THREE from 'three'; +import type { Section3D, Position3D } from '../../lib/layoutApi'; +import { PlantPosition, VisMode } from './types'; +import { PlantSystem } from './PlantSystem'; + +interface SmartRackProps { + section: Section3D; + visMode: VisMode; + onPlantClick: (plant: PlantPosition) => void; +} + +export function SmartRack({ section, visMode, onPlantClick }: SmartRackProps) { + const positions: PlantPosition[] = useMemo(() => { + const spacing = 0.5; + return section.positions.map((pos: Position3D) => ({ + ...pos, + x: section.posX + (pos.column * spacing), + z: section.posY + (pos.row * spacing), + y: 0.4 + (pos.tier * 0.6), + })); + }, [section]); + + const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b); + const distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b); + const distinctCols = [...new Set(positions.map(p => p.column))].sort((a, b) => a - b); + + const spacing = 0.5; + + return ( + + {visMode === 'STANDARD' && ( + + {section.code} + + )} + + {distinctTiers.map(tier => ( + + + + + ))} + + {[0, 1].map(xOffset => + [0, 1].map(zOffset => ( + + + + + )) + )} + + {visMode === 'STANDARD' && distinctRows.map(row => ( + + R{row} + + ))} + + {visMode === 'STANDARD' && distinctCols.map(col => ( + + C{col} + + ))} + + + + ); +} diff --git a/frontend/src/components/facility3d/index.ts b/frontend/src/components/facility3d/index.ts new file mode 100644 index 0000000..a36f7cc --- /dev/null +++ b/frontend/src/components/facility3d/index.ts @@ -0,0 +1,5 @@ +export { FacilityScene } from './FacilityScene'; +export { RoomObject } from './RoomObject'; +export { SmartRack } from './SmartRack'; +export { PlantSystem } from './PlantSystem'; +export * from './types'; diff --git a/frontend/src/components/facility3d/types.ts b/frontend/src/components/facility3d/types.ts new file mode 100644 index 0000000..1185d60 --- /dev/null +++ b/frontend/src/components/facility3d/types.ts @@ -0,0 +1,37 @@ +// Re-export types from layoutApi for consistency +import type { Room3D, Section3D, Position3D, Floor3DData } from '../../lib/layoutApi'; + +export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY'; + +// Re-export API types under cleaner names +export type RoomData = Room3D; +export type SectionData = Section3D; +export type PlantPosition = Position3D & { + x: number; + y: number; + z: number; +}; + +export const COLORS = { + // Standard Modes + VEG: '#4ade80', + FLOWER: '#a855f7', + DRY: '#f59e0b', + CURE: '#78716c', + EMPTY_SLOT: '#475569', + ROOM_FLOOR: '#334155', + ROOM_WALL: '#475569', + + // Health Mode + HEALTH_GOOD: '#22c55e', + HEALTH_WARN: '#eab308', + HEALTH_CRIT: '#ef4444', + + // Environment Heatmaps + TEMP_LOW: '#3b82f6', + TEMP_OPTIMAL: '#22c55e', + TEMP_HIGH: '#ef4444', + HUMIDITY_DRY: '#f59e0b', + HUMIDITY_OPTIMAL: '#0ea5e9', + HUMIDITY_WET: '#3b82f6', +} as const; diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index f09050e..804bf12 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -1,79 +1,22 @@ -import { useEffect, useState, Suspense, useMemo, Component, ReactNode, useRef } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { Text, Instances, Instance, Html, CameraControls, Environment, ContactShadows } from '@react-three/drei'; -import * as THREE from 'three'; -import { layoutApi, Floor3DData } from '../lib/layoutApi'; +import { useEffect, useState, Suspense, Component, ReactNode } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { Html, CameraControls } from '@react-three/drei'; +import { layoutApi, Floor3DData, Room3D, Position3D } from '../lib/layoutApi'; import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf } from 'lucide-react'; import { Link, useSearchParams } from 'react-router-dom'; +import { FacilityScene } from '../components/facility3d/FacilityScene'; +import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types'; -// --- Visualization Types & Config --- -type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY'; - -const COLORS = { - // Standard Modes - 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 - - // Health Mode - HEALTH_GOOD: '#22c55e', - HEALTH_WARN: '#eab308', - HEALTH_CRIT: '#ef4444', - - // Environment Heatmaps (Low -> High) - TEMP_LOW: '#3b82f6', // Cold (Blue) - TEMP_OPTIMAL: '#22c55e', // Good (Green) - TEMP_HIGH: '#ef4444', // Hot (Red) - - HUMIDITY_DRY: '#f59e0b', // Dry (Amber) - HUMIDITY_OPTIMAL: '#0ea5e9', // Good (Sky Blue) - HUMIDITY_WET: '#3b82f6', // Wet (Dark Blue) -}; - -// --- Mock Data Generators --- -const getMockRoomEnv = (roomName: string) => { - const hash = roomName.split('').reduce((a, b) => a + b.charCodeAt(0), 0); - const temp = 68 + (hash % 15); // 68-83 deg F - const humidity = 40 + (hash % 40); // 40-80% RH - return { temp, humidity }; -}; - -const getMockPlantHealth = (plantId: string) => { - const r = Math.random(); // Deterministic enough for this demo if we seed it, but random is okay for flashiness - if (r > 0.95) return 'CRITICAL'; - if (r > 0.85) return 'WARNING'; - return 'GOOD'; -}; - -const lerpColor = (c1: string, c2: string, t: number) => { - const c1c = new THREE.Color(c1); - const c2c = new THREE.Color(c2); - return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString(); -}; - -// Define State interface -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} +// --- Error Boundary --- +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 }; - } - + state: ErrorBoundaryState = { 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 (
@@ -87,372 +30,40 @@ class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryStat } } -function PlantInstances({ positions, onPlantClick, visMode }: { - positions: any[], - onPlantClick: (p: any) => void, - visMode: VisMode -}) { - if (!positions || !Array.isArray(positions) || positions.length === 0) return null; - - 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); - - if (safePositions.length > 5000) return null; - - return ( - - {/* Active Plants */} - {plants.length > 0 && ( - { - e.stopPropagation(); - const index = e.instanceId; - if (index !== undefined && plants[index]) { - onPlantClick(plants[index]); - } - }} - > - {/* Bushy Top */} - - - {plants.map((pos, i) => { - let color = COLORS.VEG; - - switch (visMode) { - case 'STANDARD': - color = pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER : - pos.plant?.stage === 'DRYING' ? COLORS.DRY : - pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG; - break; - case 'HEALTH': - const status = getMockPlantHealth(pos.plant.id); - color = status === 'CRITICAL' ? COLORS.HEALTH_CRIT : - status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD; - break; - case 'YIELD': - color = lerpColor('#86efac', '#14532d', Math.random()); - break; - default: - color = '#555'; - break; - } - - // Lift plant slightly to sit ON shelf, not IN it - return ( - - ); - })} - - )} - - {/* Empty Slots - Refined */} - {emptySlots.length > 0 && visMode === 'STANDARD' && ( - - - - {emptySlots.map((pos, i) => ( - - ))} - - )} - - ); -} - -function FacilityScene({ data, onSelectPlant, targetView, setControls, visMode }: { - data: Floor3DData, - onSelectPlant: (p: any) => void, - targetView: { x: number, y: number, z: number, zoom?: boolean } | null, - setControls: (c: CameraControls | null) => void, - visMode: VisMode -}) { - const controlsRef = useRef(null); - const [keysPressed, setKeysPressed] = useState>({}); - - useEffect(() => { - if (controlsRef.current) setControls(controlsRef.current); - }, [controlsRef, setControls]); - - useEffect(() => { - if (targetView && controlsRef.current) { - const { x, y, z, zoom } = targetView; - const dist = zoom ? 15 : 40; - const height = zoom ? 15 : 40; - controlsRef.current.setLookAt(x + dist, height, z + dist, x, 0, z, true); - } - }, [targetView]); - - // RESTORED: Keyboard Event Listeners for WASD - useEffect(() => { - const handleDown = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: true })); - const handleUp = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: false })); - - window.addEventListener('keydown', handleDown); - window.addEventListener('keyup', handleUp); - return () => { - window.removeEventListener('keydown', handleDown); - window.removeEventListener('keyup', handleUp); - }; - }, []); - - useFrame((_, delta) => { - if (!controlsRef.current) return; - const speed = 20 * delta; - if (keysPressed['KeyW'] || keysPressed['ArrowUp']) controlsRef.current.forward(speed, true); - if (keysPressed['KeyS'] || keysPressed['ArrowDown']) controlsRef.current.forward(-speed, true); - if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) controlsRef.current.truck(-speed, 0, true); - if (keysPressed['KeyD'] || keysPressed['ArrowRight']) controlsRef.current.truck(speed, 0, true); - }); - - const roomMeshes = useMemo(() => { - return data.rooms.map(room => { - const env = getMockRoomEnv(room.name); - - // Determine floor color based on Env Visualization Mode - let floorColor = COLORS.ROOM_FLOOR; - let showLabel = true; - - if (visMode === 'TEMP') { - // Map 65-85F range to Blue->Green->Red - const t = (env.temp - 65) / 20; - if (t < 0.5) floorColor = lerpColor(COLORS.TEMP_LOW, COLORS.TEMP_OPTIMAL, t * 2); - else floorColor = lerpColor(COLORS.TEMP_OPTIMAL, COLORS.TEMP_HIGH, (t - 0.5) * 2); - } else if (visMode === 'HUMIDITY') { - // Map 40-80% range to Amber->Blue - const t = (env.humidity - 40) / 40; - if (t < 0.5) floorColor = lerpColor(COLORS.HUMIDITY_DRY, COLORS.HUMIDITY_OPTIMAL, t * 2); - else floorColor = lerpColor(COLORS.HUMIDITY_OPTIMAL, COLORS.HUMIDITY_WET, (t - 0.5) * 2); - } - - return ( - - - {showLabel ? room.name : ''} - - - {/* Env Data Label Overlay - INCREASED SIZE significantly */} - {(visMode === 'TEMP' || visMode === 'HUMIDITY') && ( - - {visMode === 'TEMP' ? `${env.temp.toFixed(1)}°F` : `${env.humidity.toFixed(0)}% RH`} - - )} - - - - - - - - - - - - {room.sections.map(section => { - const positions = section.positions.map(pos => { - const spacing = 0.5; - const x = section.posX + (pos.column * spacing); - const z = section.posY + (pos.row * spacing); - const y = 0.5 + (pos.tier * 0.8); // Increased vertical spacing for tiers - return { ...pos, x, y, z }; - }); - - // Calculate visual bounds for labels - const distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b); - const distinctCols = [...new Set(positions.map(p => p.column))].sort((a, b) => a - b); - const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b); - - const spacing = 0.5; - - return ( - - {/* Section Label */} - - {visMode === 'STANDARD' ? section.code : ''} - - - {/* VISUAL CUES: Shelves per Tier */} - {distinctTiers.map(tier => ( - - {/* Shelf - Lighter for visibility */} - - - - ))} - - {/* ROW LABELS (Along Z-axis) */} - {visMode === 'STANDARD' && distinctRows.map(row => ( - - R{row} - - ))} - - {/* COLUMN LABELS (Along X-axis) */} - {visMode === 'STANDARD' && distinctCols.map(col => ( - - C{col} - - ))} - - - - ); - })} - - ); - }); - }, [data, onSelectPlant, visMode]); - - return ( - <> - - {/* Increased brightness */} - - - - {roomMeshes} - - - - - - - ); -} - +// --- Main Component --- export default function Facility3DViewerPage() { const [status, setStatus] = useState('Initializing...'); const [floorData, setFloorData] = useState(null); - const [selectedPlant, setSelectedPlant] = useState(null); - const [targetView, setTargetView] = useState<{ x: number, y: number, z: number, zoom?: boolean } | null>(null); - const [controls, setControls] = useState(null); + const [selectedPlant, setSelectedPlant] = useState(null); + const [targetView, setTargetView] = useState<{ x: number; y: number; z: number; zoom?: boolean } | null>(null); + const [_controls, setControls] = useState(null); const [visMode, setVisMode] = useState('STANDARD'); const [searchParams] = useSearchParams(); const targetedPlantTag = searchParams.get('plant'); - useEffect(() => { loadData(); }, []); + useEffect(() => { + loadData(); + }, []); useEffect(() => { if (floorData && targetedPlantTag) { - let foundPlant: any = null; - let foundRoom: any = null; for (const room of floorData.rooms) { for (const section of room.sections) { - const match = section.positions.find(p => p.plant?.tagNumber === targetedPlantTag); + const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag); if (match) { - foundPlant = match; - foundRoom = room; - break; + const spacing = 0.5; + const plant: PlantPosition = { + ...match, + x: room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2, + z: room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2, + y: 0.4 + (match.tier * 0.6), + }; + setSelectedPlant(plant); + setTargetView({ x: plant.x, y: 0, z: plant.z, zoom: true }); + return; } } - if (foundPlant) break; - } - - if (foundPlant && foundRoom) { - const offsetX = -floorData.floor.width / 2; - const offsetZ = -floorData.floor.height / 2; - setSelectedPlant(foundPlant); - const roomCenterX = foundRoom.posX + (foundRoom.width / 2) + offsetX; - const roomCenterZ = foundRoom.posY + (foundRoom.height / 2) + offsetZ; - setTargetView({ x: roomCenterX, y: 0, z: roomCenterZ, zoom: true }); } } }, [floorData, targetedPlantTag]); @@ -463,32 +74,35 @@ export default function Facility3DViewerPage() { 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...'); + setStatus('Fetching 3D scene...'); const data = await layoutApi.getFloor3D(floorId); setFloorData(data); setStatus(''); } else { - setStatus('No floor layout found'); + setStatus('No floor layout found. Set up a facility first.'); } } catch (err) { setStatus('Error: ' + (err as Error).message); } } - const focusRoom = (room: any) => { + const focusRoom = (room: Room3D) => { if (!floorData) return; const offsetX = -floorData.floor.width / 2; const offsetZ = -floorData.floor.height / 2; - const centerX = room.posX + (room.width / 2) + offsetX; - const centerZ = room.posY + (room.height / 2) + offsetZ; - setTargetView({ x: centerX, y: 0, z: centerZ, zoom: true }); + setTargetView({ + x: room.posX + room.width / 2 + offsetX, + y: 0, + z: room.posY + room.height / 2 + offsetZ, + zoom: true, + }); }; const resetView = () => setTargetView({ x: 0, y: 0, z: 0, zoom: false }); return ( -
- {/* Header Overlay */} +
+ {/* Header */}
@@ -496,67 +110,67 @@ export default function Facility3DViewerPage() {

- Facility Viewer 3D + Facility 3D BETA

-

+

{floorData ? `${floorData.floor.name} • ${floorData.stats.occupiedPositions} Plants` : 'Loading...'}

- {/* Legend - Dynamic based on Mode */} -
+ {/* Legend */} +
{visMode === 'STANDARD' && ( <> -
Veg
-
Flower
-
Dry
+
Veg
+
Flower
+
Dry
)} {visMode === 'HEALTH' && ( <> -
Good
-
Check
-
Critical
+
Good
+
Check
+
Critical
)} {visMode === 'TEMP' && ( <> -
<65°F
-
75°F
-
>85°F
+
<65°F
+
75°F
+
>85°F
)} {visMode === 'HUMIDITY' && ( <> -
<40%
-
60%
-
>80%
+
<40%
+
60%
+
>80%
)}
- {/* Quick Nav Sidebar (Standard) */} + {/* Room Navigation Sidebar */} {floorData && ( -
-
+
+
- Navigation -
-
- {floorData.rooms.map(room => ( +
+ {floorData.rooms.map((room: Room3D) => ( ))}
@@ -564,61 +178,36 @@ export default function Facility3DViewerPage() {
)} - {/* Data Vis Controls (Floating Right) */} -
- - - - - - - - - + {/* Visualization Mode Buttons */} +
+ {[ + { mode: 'STANDARD' as VisMode, icon: Layers, label: 'Layout', active: 'bg-primary' }, + { mode: 'HEALTH' as VisMode, icon: Activity, label: 'Health', active: 'bg-red-500' }, + { mode: 'TEMP' as VisMode, icon: Thermometer, label: 'Temp', active: 'bg-orange-500' }, + { mode: 'HUMIDITY' as VisMode, icon: Droplets, label: 'VPD', active: 'bg-blue-500' }, + { mode: 'YIELD' as VisMode, icon: Leaf, label: 'Yield', active: 'bg-green-600' }, + ].map(({ mode, icon: Icon, label, active }) => ( + + ))}
- - {/* Error/Status Overlay */} + {/* Loading/Error Overlay */} {status && ( -
+
- -

{status}

+ +

{status}

{status.includes('Error') && (
)} - {/* Selection Overlay */} - {selectedPlant && ( -
-
+ {/* Plant Selection Panel */} + {selectedPlant?.plant && ( +
+
- {selectedPlant.plant.metrcTag ? 'METRC TAG' : 'PLANT ID'} -

{selectedPlant.plant.tagNumber}

+ + {selectedPlant.plant.batchId ? 'METRC TAG' : 'PLANT ID'} + +

{selectedPlant.plant.tagNumber}

- +
-
+
- Strain - {selectedPlant.plant.strain || 'Unknown'} + Strain + {selectedPlant.plant.strain || 'Unknown'}
- Stage + Stage {selectedPlant.plant.stage || 'N/A'}
-
+
- Batch - {selectedPlant.plant.batchName || '-'} + Batch + {selectedPlant.plant.batchName || '—'}
- Position - R{selectedPlant.row} • C{selectedPlant.column} • T{selectedPlant.tier} + Position + R{selectedPlant.row} • C{selectedPlant.column} • T{selectedPlant.tier}
- {visMode === 'HEALTH' && ( -
- Risk Factor - High -
- )}
)} - + {/* 3D Canvas */} + Loading 3D Scene...}> {floorData && ( )}