From 1950651102bbfd76ffa06a95bca5a7158d68f83d Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:46:00 -0800 Subject: [PATCH] feat: Implement Parametric Room Generation and Volumetric Plants --- backend/src/routes/layout.routes.ts | 116 ++++++++- .../src/components/facility3d/PlantIcon.tsx | 4 +- .../src/components/facility3d/PlantSystem.tsx | 13 +- .../components/facility3d/VolumetricPlant.tsx | 89 +++++++ frontend/src/components/facility3d/types.ts | 2 + .../components/layout/RoomLayoutWizard.tsx | 240 ++++++++++++++++++ frontend/src/lib/layoutApi.ts | 15 ++ frontend/src/pages/RoomsPage.tsx | 43 +++- 8 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/facility3d/VolumetricPlant.tsx create mode 100644 frontend/src/components/layout/RoomLayoutWizard.tsx diff --git a/backend/src/routes/layout.routes.ts b/backend/src/routes/layout.routes.ts index 7340882..8e2cb32 100644 --- a/backend/src/routes/layout.routes.ts +++ b/backend/src/routes/layout.routes.ts @@ -301,7 +301,121 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu // ROOM ROUTES // ======================================== - // Create room + // Generate Room with Layout (Parametric) + fastify.post('/rooms/generate', { + handler: async (request, reply) => { + try { + const { + floorId, name, code, type, + setupType, // RACK, TABLE + tiers, // 1-5 + racksCount, // Total number of racks + rowsPerRack, + colsPerRack + } = request.body as any; + + const numTiers = tiers || 1; + const numRacks = racksCount || 1; + const numRows = rowsPerRack || 4; + const numCols = colsPerRack || 2; + + // Standard dimensions (approx 4x8 table scaled) + const rackWidth = 40; + const rackHeight = 80; + const aisleWidth = 30; + const padding = 20; + + // Calculate room size approx + // Simple layout: 2 rows of racks if possible, else 1 long row + const racksPerRow = Math.ceil(numRacks / 2); + const numRackRows = Math.ceil(numRacks / racksPerRow); + + const roomWidth = (padding * 2) + (racksPerRow * rackWidth) + ((racksPerRow - 1) * aisleWidth); + const roomHeight = (padding * 2) + (numRackRows * rackHeight) + ((numRackRows - 1) * aisleWidth); + + // Create the Room + const room = await prisma.facilityRoom.create({ + data: { + floorId, + name, + code, + type: type as RoomType, + posX: 0, // Default to 0,0 - user can move + posY: 0, + width: Math.max(roomWidth, 100), + height: Math.max(roomHeight, 100), + color: type === 'FLOWER' ? '#10b981' : '#3b82f6' + } + }); + + // Generate Racks (Sections) + const sectionsToCreate = []; + let rackCounter = 1; + + for (let r = 0; r < numRackRows; r++) { + for (let c = 0; c < racksPerRow; c++) { + if (rackCounter > numRacks) break; + + const sPosX = padding + (c * (rackWidth + aisleWidth)); + const sPosY = padding + (r * (rackHeight + aisleWidth)); + + // Generate positions for this rack + const positionsToCreate = []; + for (let row = 1; row <= numRows; row++) { + for (let col = 1; col <= numCols; col++) { + for (let t = 1; t <= numTiers; t++) { + positionsToCreate.push({ + row, + column: col, + tier: t, + slot: 1, + status: 'EMPTY' + }); + } + } + } + + sectionsToCreate.push(prisma.facilitySection.create({ + data: { + roomId: room.id, + name: `${setupType} ${rackCounter}`, + code: `${code}-R${rackCounter}`, + type: (setupType || 'RACK') as SectionType, + posX: sPosX, + posY: sPosY, + width: rackWidth, + height: rackHeight, + rows: numRows, + columns: numCols, + tiers: numTiers, + spacing: 12, + positions: { + create: positionsToCreate + } + } + })); + rackCounter++; + } + } + + await prisma.$transaction(sectionsToCreate); + + // Fetch full room to return + const fullRoom = await prisma.facilityRoom.findUnique({ + where: { id: room.id }, + include: { sections: true } + }); + + return reply.status(201).send(fullRoom); + + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Failed to generate room layout' }); + } + } + }); + + fastify.post('/rooms', { handler: async (request, reply) => { try { diff --git a/frontend/src/components/facility3d/PlantIcon.tsx b/frontend/src/components/facility3d/PlantIcon.tsx index 2433d4a..eb52f0b 100644 --- a/frontend/src/components/facility3d/PlantIcon.tsx +++ b/frontend/src/components/facility3d/PlantIcon.tsx @@ -1,12 +1,11 @@ import { useMemo } from 'react'; import { Billboard } from '@react-three/drei'; import * as THREE from 'three'; +import { PlantStage } from './types'; // Stage-specific icon shapes as SVG paths (camera-facing 2D) // These are custom illustrations optimized for clarity at small sizes -export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED'; - interface PlantIconProps { stage: PlantStage; color: string; @@ -26,6 +25,7 @@ const STAGE_ICONS: Record { @@ -133,17 +134,17 @@ export function PlantSystem({ }).filter(p => p.stage !== 'HIDDEN' && p.stage !== 'FINISHED'); // Filter out plants that don't exist at this time }, [plants, visMode, highlightSet, dimMode, hasHighlights, timelineDate]); - // New icon-based rendering + // New volumetric/icon based rendering if (useIcons) { return ( {plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => ( - onPlantClick(pos)} diff --git a/frontend/src/components/facility3d/VolumetricPlant.tsx b/frontend/src/components/facility3d/VolumetricPlant.tsx new file mode 100644 index 0000000..b3603de --- /dev/null +++ b/frontend/src/components/facility3d/VolumetricPlant.tsx @@ -0,0 +1,89 @@ +import { useMemo, useRef } from 'react'; +import { Instance, Instances } from '@react-three/drei'; +import * as THREE from 'three'; +import { PlantStage } from './types'; + +interface VolumetricPlantProps { + stage: PlantStage; + color: string; + position: [number, number, number]; + scale?: number; + onClick?: () => void; + isHighlighted?: boolean; + isDimmed?: boolean; +} + +export function VolumetricPlant({ + stage, + color, + position, + scale = 1, + onClick, + isHighlighted, + isDimmed +}: VolumetricPlantProps) { + + // Geometry decision based on stage + // CLONE: Small cylinder + // VEG: Bushy sphere + // FLOWER: Tall cone (Christmas tree shape) + // DRYING: Inverted cone or just color change + + const meshRef = useRef(null); + + const adjustedColor = isDimmed ? '#3f3f46' : color; + const highlightScale = isHighlighted ? 1.2 : 1; + const baseScale = scale * highlightScale; + + // Use standard primitives + if (stage === 'CLONE_IN') { + return ( + { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}> + + + + ); + } + + if (stage === 'VEGETATIVE') { + return ( + { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}> + + + + ); + } + + if (stage === 'FLOWERING') { + return ( + { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}> + {/* Main Cola */} + + + + + {/* Visual interest / Buds */} + + + + + + + + + + ); + } + + // Default / Harvested / Etc + return ( + { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}> + + + + ); +} + +// Optimized Batch Renderer needed? +// For now, individual components are fine unless we hit >1000 plants in view. +// If needed, we can switch to for each shape type. diff --git a/frontend/src/components/facility3d/types.ts b/frontend/src/components/facility3d/types.ts index 5774ae5..633097b 100644 --- a/frontend/src/components/facility3d/types.ts +++ b/frontend/src/components/facility3d/types.ts @@ -3,6 +3,8 @@ import type { Room3D, Section3D, Position3D, Floor3DData } from '../../lib/layou export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY'; +export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED' | 'HIDDEN'; + // Breadcrumb data for hierarchy navigation export interface PlantBreadcrumb { facility?: string; diff --git a/frontend/src/components/layout/RoomLayoutWizard.tsx b/frontend/src/components/layout/RoomLayoutWizard.tsx new file mode 100644 index 0000000..29f4e61 --- /dev/null +++ b/frontend/src/components/layout/RoomLayoutWizard.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { Slider } from '../ui/slider'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; +import { api } from '../../lib/api'; +import { layoutApi } from '../../lib/layoutApi'; +import { Loader2, Wand2, Box, Layers, Grid } from 'lucide-react'; + +interface RoomLayoutWizardProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + floorId: string; +} + +export function RoomLayoutWizard({ isOpen, onClose, onSuccess, floorId }: RoomLayoutWizardProps) { + const [step, setStep] = useState(1); // 1: Basics, 2: Layout, 3: Capacity + const [loading, setLoading] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + type: 'FLOWER', + setupType: 'RACK', // RACK, TABLE + racksCount: 4, + tiers: 2, + rowsPerRack: 4, + colsPerRack: 2 + }); + + // Estimates + const totalPlants = formData.racksCount * formData.rowsPerRack * formData.colsPerRack * formData.tiers; + + const handleGenerate = async () => { + setLoading(true); + try { + await layoutApi.generateRoom({ + floorId, + code: formData.name.toUpperCase().substring(0, 4), // Auto-code + ...formData + }); + onSuccess(); + onClose(); + } catch (error) { + console.error('Failed to generate room:', error); + // In a real app, show toast + } finally { + setLoading(false); + } + }; + + return ( + + + + + + Parametric Room Generator + + + +
+ + + setStep(1)}>1. Basics + setStep(2)}>2. Layout + setStep(3)}>3. Review + + + {/* STEP 1: BASICS */} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="bg-zinc-900 border-zinc-700" + /> +
+
+ + +
+
+ +
+ +
+ + +
+
+
+ + {/* STEP 2: LAYOUT */} + +
+
+ + {formData.racksCount} +
+ setFormData({ ...formData, racksCount: val })} + min={1} max={20} step={1} + className="py-4" + /> +
+ +
+
+ + {formData.tiers} +
+
+ {[1, 2, 3, 4, 5].map(t => ( + + ))} +
+
+ +
+
+ + setFormData({ ...formData, rowsPerRack: parseInt(e.target.value) || 4 })} + className="bg-zinc-900 border-zinc-700" + /> +
+
+ + setFormData({ ...formData, colsPerRack: parseInt(e.target.value) || 2 })} + className="bg-zinc-900 border-zinc-700" + /> +
+
+
+ + {/* STEP 3: REVIEW */} + +
+

Total Capacity Estimate

+ +
+
+

Total Plant Sites

+

{totalPlants}

+
+
+

Canopy Sq. Ft (Est)

+

+ {totalPlants * 1.5} sq ft +

+
+
+ +
+
+ Units: + {formData.racksCount} {formData.setupType}s +
+
+ Tiers: + {formData.tiers} +
+
+ Grid: + {formData.rowsPerRack} x {formData.colsPerRack} +
+
+
+
+
+
+ + + {step > 1 ? ( + + ) : ( +
// Spacer + )} + + {step < 3 ? ( + + ) : ( + + )} + + +
+ ); +} diff --git a/frontend/src/lib/layoutApi.ts b/frontend/src/lib/layoutApi.ts index d8a187c..33f5272 100644 --- a/frontend/src/lib/layoutApi.ts +++ b/frontend/src/lib/layoutApi.ts @@ -205,6 +205,21 @@ export const layoutApi = { return response.data; }, + async generateRoom(data: { + floorId: string; + name: string; + code: string; + type: string; + setupType: string; + tiers: number; + racksCount: number; + rowsPerRack: number; + colsPerRack: number; + }): Promise { + const response = await api.post('/layout/rooms/generate', data); + return response.data; + }, + // Rooms async createRoom(data: Partial): Promise { const response = await api.post('/layout/rooms', data); diff --git a/frontend/src/pages/RoomsPage.tsx b/frontend/src/pages/RoomsPage.tsx index 432153e..f9e0692 100644 --- a/frontend/src/pages/RoomsPage.tsx +++ b/frontend/src/pages/RoomsPage.tsx @@ -1,15 +1,17 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Home, Plus, Thermometer, Droplets, ChevronRight, Activity, Leaf, Flower, ShieldCheck, ArrowRight } from 'lucide-react'; +import { Home, Plus, Thermometer, Droplets, ChevronRight, Activity, Leaf, Flower, ShieldCheck, ArrowRight, Wand2 } from 'lucide-react'; import api from '../lib/api'; import { usePermissions } from '../hooks/usePermissions'; import { Card } from '../components/ui/card'; import { cn } from '../lib/utils'; +import { RoomLayoutWizard } from '../components/layout/RoomLayoutWizard'; export default function RoomsPage() { const { isManager } = usePermissions(); const [rooms, setRooms] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isWizardOpen, setIsWizardOpen] = useState(false); useEffect(() => { fetchRooms(); @@ -59,10 +61,19 @@ export default function RoomsPage() { {isManager && ( - +
+ + +
)} @@ -122,9 +133,17 @@ export default function RoomsPage() {

No Cultivation Zones Configured

{isManager && ( - +
+ + +
)} ) : ( @@ -205,7 +224,13 @@ export default function RoomsPage() { })} )} + + setIsWizardOpen(false)} + onSuccess={fetchRooms} + floorId="default-floor-id" + /> ); } -