From 36dbeb23c33ff1fb7b97a9d914a47eeec59e8692 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:38:06 -0800 Subject: [PATCH] feat: 3D Facility Viewer with React Three Fiber - Added Facility3DViewerPage with interactive 3D scene - Shows floors, rooms, sections, and plant positions - Color-coded by room type and position status - Orbit controls for rotation/zoom/pan - Floor selector dropdown - Stats panel showing totals - Added /layout/floors/:id/3d API endpoint - Fixed TypeScript issues in DocumentsPage --- frontend/src/lib/documentsApi.ts | 19 +- frontend/src/lib/layoutApi.ts | 72 ++++ frontend/src/lib/navigation.ts | 1 + frontend/src/pages/DocumentsPage.tsx | 6 +- frontend/src/pages/Facility3DViewerPage.tsx | 452 ++++++++++++++++++++ frontend/src/router.tsx | 21 + 6 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/Facility3DViewerPage.tsx diff --git a/frontend/src/lib/documentsApi.ts b/frontend/src/lib/documentsApi.ts index e64f33d..7cff225 100644 --- a/frontend/src/lib/documentsApi.ts +++ b/frontend/src/lib/documentsApi.ts @@ -68,7 +68,7 @@ export const documentsApi = { }, async getDocument(id: string): Promise { - const response = await api.get(`/api/documents/${id}`); + const response = await api.get(`/documents/${id}`); return response.data; }, @@ -78,36 +78,36 @@ export const documentsApi = { }, async updateDocument(id: string, data: UpdateDocumentData): Promise { - const response = await api.patch(`/api/documents/${id}`, data); + const response = await api.patch(`/documents/${id}`, data); return response.data; }, async deleteDocument(id: string): Promise { - await api.delete(`/api/documents/${id}`); + await api.delete(`/documents/${id}`); }, async getVersions(id: string): Promise { - const response = await api.get(`/api/documents/${id}/versions`); + const response = await api.get(`/documents/${id}/versions`); return response.data; }, async submitForApproval(id: string): Promise { - const response = await api.post(`/api/documents/${id}/submit`); + const response = await api.post(`/documents/${id}/submit`); return response.data; }, async approveDocument(id: string): Promise { - const response = await api.post(`/api/documents/${id}/approve`); + const response = await api.post(`/documents/${id}/approve`); return response.data; }, async rejectDocument(id: string, reason?: string): Promise { - const response = await api.post(`/api/documents/${id}/reject`, { reason }); + const response = await api.post(`/documents/${id}/reject`, { reason }); return response.data; }, async acknowledgeDocument(id: string): Promise<{ success: boolean; acknowledgedAt: string }> { - const response = await api.post(`/api/documents/${id}/acknowledge`); + const response = await api.post(`/documents/${id}/acknowledge`); return response.data; }, @@ -117,7 +117,7 @@ export const documentsApi = { acknowledged: DocumentAck[]; pending: { id: string; name: string }[]; }> { - const response = await api.get(`/api/documents/${id}/acks`); + const response = await api.get(`/documents/${id}/acks`); return response.data; }, @@ -126,3 +126,4 @@ export const documentsApi = { return response.data; } }; + diff --git a/frontend/src/lib/layoutApi.ts b/frontend/src/lib/layoutApi.ts index 13a5561..d8a187c 100644 --- a/frontend/src/lib/layoutApi.ts +++ b/frontend/src/lib/layoutApi.ts @@ -74,6 +74,71 @@ export interface LayoutSection { positions?: LayoutPosition[]; } +// 3D Visualization Types +export interface Position3D { + id: string; + row: number; + column: number; + tier: number; + status: string; + plant: { + id: string; + tagNumber: string; + status: string; + batchId: string | null; + batchName: string | null; + strain: string | null; + stage: string | null; + } | null; +} + +export interface Section3D { + id: string; + name: string; + code: string; + type: string; + posX: number; + posY: number; + width: number; + height: number; + rows: number; + columns: number; + positions: Position3D[]; +} + +export interface Room3D { + id: string; + name: string; + code: string; + type: string; + posX: number; + posY: number; + width: number; + height: number; + color: string | null; + sections: Section3D[]; +} + +export interface Floor3DData { + floor: { + id: string; + name: string; + number: number; + width: number; + height: number; + building: string; + property: string; + }; + rooms: Room3D[]; + stats: { + totalRooms: number; + totalSections: number; + totalPositions: number; + occupiedPositions: number; + }; +} + + // ======================================== // API Functions // ======================================== @@ -115,6 +180,13 @@ export const layoutApi = { return response.data; }, + // 3D Visualization + async getFloor3D(id: string): Promise { + const response = await api.get(`/layout/floors/${id}/3d`); + return response.data; + }, + + async saveFloorLayout(floorId: string, rooms: LayoutRoom[]): Promise { const roomsForApi = rooms.map(r => ({ id: r.id, diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index fa5262f..715d5e6 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -126,6 +126,7 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { id: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList }, { id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText }, + { id: 'facility-3d', label: '3D Facility View', path: '/facility-3d', icon: Grid3X3, minRole: 'MANAGER' }, { id: 'layout', label: 'Layout Designer', path: '/layout-designer', icon: Grid3X3, minRole: 'ADMIN' }, ] }, diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index e701eff..2bcc1f4 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -2,12 +2,13 @@ import { useState, useEffect } from 'react'; import { FileText, Search, Plus, Clock, User, Check, X, AlertCircle, Eye, Edit, FolderOpen, Book, - ClipboardList, FileCheck, HelpCircle, History, Download, Loader2 + ClipboardList, FileCheck, HelpCircle, History, Download, Loader2, + LucideIcon } from 'lucide-react'; import { documentsApi, Document, DocumentType, DocumentStatus, DocumentVersion } from '../lib/documentsApi'; import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; -const TYPE_CONFIG: Record = { +const TYPE_CONFIG: Record = { SOP: { icon: Book, badge: 'badge-accent', label: 'SOP' }, POLICY: { icon: FileCheck, badge: 'badge-accent', label: 'Policy' }, FORM: { icon: ClipboardList, badge: 'badge-accent', label: 'Form' }, @@ -16,6 +17,7 @@ const TYPE_CONFIG: Record = { DRAFT: { badge: 'badge', label: 'Draft' }, PENDING_APPROVAL: { badge: 'badge-warning', label: 'Pending' }, diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx new file mode 100644 index 0000000..a8c36bf --- /dev/null +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -0,0 +1,452 @@ +import { useEffect, useState, useRef } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { OrbitControls, Text, PerspectiveCamera } from '@react-three/drei'; +import { Loader2, Grid3X3, ChevronDown, ZoomIn, ZoomOut, RotateCcw, Building } from 'lucide-react'; +import { layoutApi, Floor3DData, Room3D, Section3D, Position3D } from '../lib/layoutApi'; +import * as THREE from 'three'; + +// Color mapping for room types +const ROOM_COLORS: Record = { + VEG: '#22c55e', // Green + FLOWER: '#a855f7', // Purple + MOTHER: '#3b82f6', // Blue + DRY: '#f97316', // Orange + CURE: '#92400e', // Brown + CLONE: '#14b8a6', // Teal + FACILITY: '#6b7280' // Gray +}; + +// Status colors for positions +const STATUS_COLORS: Record = { + EMPTY: '#4b5563', + OCCUPIED: '#22c55e', + RESERVED: '#3b82f6', + DAMAGED: '#dc2626' +}; + +// Scale factor - convert pixels to 3D units +const SCALE = 0.01; + +// Ground plane component +function Ground({ width, height }: { width: number; height: number }) { + return ( + + + + + ); +} + +// Grid overlay +function GridOverlay({ width, height }: { width: number; height: number }) { + return ( + + ); +} + +// Room box component +function Room3DBox({ room, onClick, isSelected }: { + room: Room3D; + onClick: () => void; + isSelected: boolean; +}) { + const color = ROOM_COLORS[room.type] || '#6b7280'; + const mesh = useRef(null); + + // Center the room + const x = room.posX * SCALE + (room.width * SCALE) / 2; + const z = room.posY * SCALE + (room.height * SCALE) / 2; + const roomHeight = 2; // Room height in 3D units + + return ( + + {/* Floor */} + + + + + + {/* Walls (outline) */} + + + + + + {/* Room label */} + + {room.code} + + + {/* Sections */} + {room.sections.map(section => ( + + ))} + + ); +} + +// Section (table/rack) component +function Section3DBox({ section, roomX, roomY }: { + section: Section3D; + roomX: number; + roomY: number; +}) { + const x = (roomX + section.posX) * SCALE + (section.width * SCALE) / 2; + const z = (roomY + section.posY) * SCALE + (section.height * SCALE) / 2; + const tableHeight = 0.5; + + return ( + + {/* Table surface */} + + + + + + {/* Positions (plants) */} + {section.positions.map((pos, idx) => { + const cellWidth = section.width / section.columns; + const cellHeight = section.height / section.rows; + const posX = (roomX + section.posX + (pos.column - 0.5) * cellWidth) * SCALE; + const posZ = (roomY + section.posY + (pos.row - 0.5) * cellHeight) * SCALE; + + return ( + + ); + })} + + ); +} + +// Plant position component +function PlantPosition({ position, x, y, z }: { + position: Position3D; + x: number; + y: number; + z: number; +}) { + const color = STATUS_COLORS[position.status] || STATUS_COLORS.EMPTY; + const isOccupied = position.status === 'OCCUPIED'; + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onPointerOut={() => setHovered(false)} + > + {isOccupied ? ( + + ) : ( + + )} + + + ); +} + +// Camera controller for auto-framing +function CameraController({ floorWidth, floorHeight }: { floorWidth: number; floorHeight: number }) { + const { camera } = useThree(); + + useEffect(() => { + const maxDim = Math.max(floorWidth, floorHeight) * SCALE; + camera.position.set(maxDim * 0.8, maxDim * 1.2, maxDim * 0.8); + camera.lookAt(floorWidth * SCALE / 2, 0, floorHeight * SCALE / 2); + }, [camera, floorWidth, floorHeight]); + + return null; +} + +// Main 3D Scene +function Facility3DScene({ data, selectedRoomId, onSelectRoom }: { + data: Floor3DData; + selectedRoomId: string | null; + onSelectRoom: (id: string | null) => void; +}) { + return ( + <> + {/* Lighting */} + + + + + {/* Ground and grid */} + + + + {/* Rooms */} + {data.rooms.map(room => ( + onSelectRoom(selectedRoomId === room.id ? null : room.id)} + /> + ))} + + {/* Controls and camera */} + + + + ); +} + +// Floor selector dropdown +function FloorSelector({ + floors, + currentFloorId, + onSelect +}: { + floors: { id: string; name: string; buildingName: string }[]; + currentFloorId: string | null; + onSelect: (id: string) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const current = floors.find(f => f.id === currentFloorId); + + return ( +
+ + + {isOpen && ( +
+ {floors.map(floor => ( + + ))} +
+ )} +
+ ); +} + +// Stats panel +function StatsPanel({ stats }: { stats: Floor3DData['stats'] }) { + return ( +
+
+ Rooms: + {stats.totalRooms} +
+
+ Tables: + {stats.totalSections} +
+
+ Positions: + {stats.totalPositions} +
+
+ Occupied: + {stats.occupiedPositions} +
+
+ ); +} + +// Main component +export default function Facility3DViewerPage() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [floors, setFloors] = useState<{ id: string; name: string; buildingName: string }[]>([]); + const [currentFloorId, setCurrentFloorId] = useState(null); + const [floorData, setFloorData] = useState(null); + const [selectedRoomId, setSelectedRoomId] = useState(null); + + // Load properties and floors + useEffect(() => { + const loadFloors = async () => { + try { + const properties = await layoutApi.getProperties(); + const allFloors: { id: string; name: string; buildingName: string }[] = []; + + properties.forEach(prop => { + prop.buildings.forEach(building => { + building.floors.forEach(floor => { + allFloors.push({ + id: floor.id, + name: floor.name, + buildingName: building.name + }); + }); + }); + }); + + setFloors(allFloors); + if (allFloors.length > 0) { + setCurrentFloorId(allFloors[0].id); + } + } catch (err) { + setError('Failed to load facility data'); + } finally { + setLoading(false); + } + }; + + loadFloors(); + }, []); + + // Load 3D data when floor changes + useEffect(() => { + if (!currentFloorId) return; + + const load3DData = async () => { + setLoading(true); + try { + const data = await layoutApi.getFloor3D(currentFloorId); + setFloorData(data); + } catch (err) { + setError('Failed to load 3D floor data'); + } finally { + setLoading(false); + } + }; + + load3DData(); + }, [currentFloorId]); + + if (loading && !floorData) { + return ( +
+
+ +

Loading facility...

+
+
+ ); + } + + if (error && !floorData) { + return ( +
+
+ +

{error}

+
+
+ ); + } + + if (!floorData) { + return ( +
+
+ +

No facility data available

+

Create a facility in Settings to get started

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + 3D Facility View +

+ +
+ +
+ + {/* Legend */} +
+ Rooms: + {Object.entries(ROOM_COLORS).map(([type, color]) => ( +
+
+ {type} +
+ ))} +
+ + {/* 3D Canvas */} +
+ + + + + {/* Info overlay */} + {selectedRoomId && ( +
+

+ {floorData.rooms.find(r => r.id === selectedRoomId)?.name} +

+

+ {floorData.rooms.find(r => r.id === selectedRoomId)?.sections.length || 0} tables +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fc893e9..2b45b39 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -40,6 +40,10 @@ const EnvironmentDashboard = lazy(() => import('./pages/EnvironmentDashboard')); const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard')); const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard')); +// 3D Facility Viewer +const Facility3DViewerPage = lazy(() => import('./pages/Facility3DViewerPage')); + + // Loading spinner component for Suspense fallbacks const PageLoader = () => (
@@ -201,4 +205,21 @@ export const router = createBrowserRouter([ ), errorElement: , }, + // 3D Facility Viewer - Full screen + { + path: '/facility-3d', + element: ( + + +
+
+ }> + +
+
+ ), + errorElement: , + }, ]); +