feat: Implement Parametric Room Generation and Volumetric Plants
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-27 14:46:00 -08:00
parent 820d345a0c
commit 1950651102
8 changed files with 504 additions and 18 deletions

View file

@ -301,7 +301,121 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
// ROOM ROUTES // 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', { fastify.post('/rooms', {
handler: async (request, reply) => { handler: async (request, reply) => {
try { try {

View file

@ -1,12 +1,11 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Billboard } from '@react-three/drei'; import { Billboard } from '@react-three/drei';
import * as THREE from 'three'; import * as THREE from 'three';
import { PlantStage } from './types';
// Stage-specific icon shapes as SVG paths (camera-facing 2D) // Stage-specific icon shapes as SVG paths (camera-facing 2D)
// These are custom illustrations optimized for clarity at small sizes // These are custom illustrations optimized for clarity at small sizes
export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED';
interface PlantIconProps { interface PlantIconProps {
stage: PlantStage; stage: PlantStage;
color: string; color: string;
@ -26,6 +25,7 @@ const STAGE_ICONS: Record<PlantStage, { shape: 'circle' | 'leaf' | 'flower' | 'd
CURING: { shape: 'square' }, // Jar/container square CURING: { shape: 'square' }, // Jar/container square
HARVESTED: { shape: 'ring' }, // Empty circle HARVESTED: { shape: 'ring' }, // Empty circle
FINISHED: { shape: 'ring' }, // Empty circle FINISHED: { shape: 'ring' }, // Empty circle
HIDDEN: { shape: 'circle' }, // Placeholder
}; };
// Create geometry for each shape type // Create geometry for each shape type

View file

@ -2,8 +2,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Instances, Instance } from '@react-three/drei'; import { Instances, Instance } from '@react-three/drei';
import * as THREE from 'three'; import * as THREE from 'three';
import { PlantPosition, VisMode, COLORS } from './types'; import { PlantPosition, VisMode, COLORS, PlantStage } from './types';
import { PlantIcon, PlantStage } from './PlantIcon'; import { PlantIcon } from './PlantIcon';
import { VolumetricPlant } from './VolumetricPlant';
// Mock helpers // Mock helpers
const getMockPlantHealth = (_plantId: string) => { const getMockPlantHealth = (_plantId: string) => {
@ -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 }).filter(p => p.stage !== 'HIDDEN' && p.stage !== 'FINISHED'); // Filter out plants that don't exist at this time
}, [plants, visMode, highlightSet, dimMode, hasHighlights, timelineDate]); }, [plants, visMode, highlightSet, dimMode, hasHighlights, timelineDate]);
// New icon-based rendering // New volumetric/icon based rendering
if (useIcons) { if (useIcons) {
return ( return (
<group> <group>
{plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => ( {plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => (
<PlantIcon <VolumetricPlant
key={pos.id || i} key={pos.id || i}
stage={stage as PlantStage} stage={stage as PlantStage}
color={color} color={color}
position={[pos.x, pos.y + 0.3, pos.z]} position={[pos.x, pos.y + 0.05, pos.z]} // Lower start for volumetric base
scale={1.2} scale={1.0}
isHighlighted={isHighlighted || false} isHighlighted={isHighlighted || false}
isDimmed={shouldDim} isDimmed={shouldDim}
onClick={() => onPlantClick(pos)} onClick={() => onPlantClick(pos)}

View file

@ -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<THREE.Mesh>(null);
const adjustedColor = isDimmed ? '#3f3f46' : color;
const highlightScale = isHighlighted ? 1.2 : 1;
const baseScale = scale * highlightScale;
// Use standard primitives
if (stage === 'CLONE_IN') {
return (
<mesh position={position} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
<cylinderGeometry args={[0.05, 0.05, 0.2, 8]} />
<meshStandardMaterial color={adjustedColor} />
</mesh>
);
}
if (stage === 'VEGETATIVE') {
return (
<mesh position={[position[0], position[1] + 0.15, position[2]]} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
<sphereGeometry args={[0.25, 12, 8]} />
<meshStandardMaterial color={adjustedColor} roughness={0.8} />
</mesh>
);
}
if (stage === 'FLOWERING') {
return (
<group position={[position[0], position[1] + 0.3, position[2]]} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
{/* Main Cola */}
<mesh position={[0, 0, 0]}>
<coneGeometry args={[0.25, 0.8, 12]} />
<meshStandardMaterial color={adjustedColor} roughness={0.6} />
</mesh>
{/* Visual interest / Buds */}
<mesh position={[0.1, 0.1, 0.1]}>
<sphereGeometry args={[0.08, 6, 4]} />
<meshStandardMaterial color="#fcd34d" />
</mesh>
<mesh position={[-0.1, 0.2, -0.05]}>
<sphereGeometry args={[0.08, 6, 4]} />
<meshStandardMaterial color="#fcd34d" />
</mesh>
</group>
);
}
// Default / Harvested / Etc
return (
<mesh position={position} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
<boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color={adjustedColor} transparent opacity={0.8} />
</mesh>
);
}
// Optimized Batch Renderer needed?
// For now, individual components are fine unless we hit >1000 plants in view.
// If needed, we can switch to <Instances> for each shape type.

View file

@ -3,6 +3,8 @@ import type { Room3D, Section3D, Position3D, Floor3DData } from '../../lib/layou
export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY'; 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 // Breadcrumb data for hierarchy navigation
export interface PlantBreadcrumb { export interface PlantBreadcrumb {
facility?: string; facility?: string;

View file

@ -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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl bg-zinc-950 border-zinc-800 text-zinc-100">
<DialogHeader>
<DialogTitle className="text-xl flex items-center gap-2">
<Wand2 className="w-5 h-5 text-emerald-400" />
Parametric Room Generator
</DialogTitle>
</DialogHeader>
<div className="py-6">
<Tabs value={String(step)} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-zinc-900">
<TabsTrigger value="1" disabled={step < 1} onClick={() => setStep(1)}>1. Basics</TabsTrigger>
<TabsTrigger value="2" disabled={step < 2} onClick={() => setStep(2)}>2. Layout</TabsTrigger>
<TabsTrigger value="3" disabled={step < 3} onClick={() => setStep(3)}>3. Review</TabsTrigger>
</TabsList>
{/* STEP 1: BASICS */}
<TabsContent value="1" className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Room Name</Label>
<Input
placeholder="e.g. Flower Room 1"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-zinc-900 border-zinc-700"
/>
</div>
<div className="space-y-2">
<Label>Room Type</Label>
<Select
value={formData.type}
onValueChange={(val) => setFormData({ ...formData, type: val })}
>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="VEGETATIVE">Vegetative</SelectItem>
<SelectItem value="FLOWER">Flower</SelectItem>
<SelectItem value="DRYING">Drying</SelectItem>
<SelectItem value="CURING">Curing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Setup Type</Label>
<div className="grid grid-cols-2 gap-4">
<button
className={`p-4 rounded-xl border flex flex-col items-center gap-2 transition-all ${formData.setupType === 'RACK' ? 'bg-emerald-900/20 border-emerald-500 text-emerald-400' : 'bg-zinc-900 border-zinc-700 text-zinc-400 hover:bg-zinc-800'}`}
onClick={() => setFormData({ ...formData, setupType: 'RACK' })}
>
<Layers className="w-8 h-8" />
<span className="font-medium">Vertical Racks</span>
</button>
<button
className={`p-4 rounded-xl border flex flex-col items-center gap-2 transition-all ${formData.setupType === 'TABLE' ? 'bg-emerald-900/20 border-emerald-500 text-emerald-400' : 'bg-zinc-900 border-zinc-700 text-zinc-400 hover:bg-zinc-800'}`}
onClick={() => setFormData({ ...formData, setupType: 'TABLE' })}
>
<Grid className="w-8 h-8" />
<span className="font-medium">Rolling Tables</span>
</button>
</div>
</div>
</TabsContent>
{/* STEP 2: LAYOUT */}
<TabsContent value="2" className="space-y-6 pt-4">
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>Number of {formData.setupType === 'RACK' ? 'Racks' : 'Tables'}</Label>
<span className="text-xl font-mono text-emerald-400">{formData.racksCount}</span>
</div>
<Slider
value={[formData.racksCount]}
onValueChange={([val]) => setFormData({ ...formData, racksCount: val })}
min={1} max={20} step={1}
className="py-4"
/>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>Tiers per Rack</Label>
<span className="text-2xl font-mono text-emerald-400">{formData.tiers}</span>
</div>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(t => (
<button
key={t}
onClick={() => setFormData({ ...formData, tiers: t })}
className={`flex-1 h-12 rounded-lg border font-bold transition-all ${formData.tiers === t ? 'bg-emerald-500 text-black border-emerald-500' : 'bg-zinc-900 border-zinc-700 text-zinc-400'}`}
>
{t}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-zinc-800">
<div className="space-y-2">
<Label>Rows (Length)</Label>
<Input
type="number"
value={formData.rowsPerRack}
onChange={(e) => setFormData({ ...formData, rowsPerRack: parseInt(e.target.value) || 4 })}
className="bg-zinc-900 border-zinc-700"
/>
</div>
<div className="space-y-2">
<Label>Columns (Example: 2 trays wide)</Label>
<Input
type="number"
value={formData.colsPerRack}
onChange={(e) => setFormData({ ...formData, colsPerRack: parseInt(e.target.value) || 2 })}
className="bg-zinc-900 border-zinc-700"
/>
</div>
</div>
</TabsContent>
{/* STEP 3: REVIEW */}
<TabsContent value="3" className="space-y-6 pt-4">
<div className="bg-zinc-900/50 rounded-xl p-6 border border-zinc-800 space-y-4">
<h3 className="text-lg font-medium text-emerald-400">Total Capacity Estimate</h3>
<div className="grid grid-cols-2 gap-8">
<div>
<p className="text-sm text-zinc-400">Total Plant Sites</p>
<p className="text-4xl font-bold text-white">{totalPlants}</p>
</div>
<div>
<p className="text-sm text-zinc-400">Canopy Sq. Ft (Est)</p>
<p className="text-4xl font-bold text-white">
{totalPlants * 1.5} <span className="text-sm font-normal text-zinc-500">sq ft</span>
</p>
</div>
</div>
<div className="border-t border-zinc-800 pt-4 mt-4 grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Units:</span>
<span className="text-zinc-200">{formData.racksCount} {formData.setupType}s</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Tiers:</span>
<span className="text-zinc-200">{formData.tiers}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Grid:</span>
<span className="text-zinc-200">{formData.rowsPerRack} x {formData.colsPerRack}</span>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="flex justify-between sm:justify-between w-full">
{step > 1 ? (
<Button variant="outline" onClick={() => setStep(step - 1)} className="border-zinc-700 text-zinc-300">
Back
</Button>
) : (
<div /> // Spacer
)}
{step < 3 ? (
<Button onClick={() => setStep(step + 1)} className="bg-emerald-500 hover:bg-emerald-600 text-black">
Next
</Button>
) : (
<Button onClick={handleGenerate} disabled={loading} className="bg-emerald-500 hover:bg-emerald-600 text-black w-full sm:w-auto">
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wand2 className="w-4 h-4 mr-2" />}
Generate Room
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -205,6 +205,21 @@ export const layoutApi = {
return response.data; 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<LayoutRoom> {
const response = await api.post('/layout/rooms/generate', data);
return response.data;
},
// Rooms // Rooms
async createRoom(data: Partial<LayoutRoom>): Promise<LayoutRoom> { async createRoom(data: Partial<LayoutRoom>): Promise<LayoutRoom> {
const response = await api.post('/layout/rooms', data); const response = await api.post('/layout/rooms', data);

View file

@ -1,15 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; 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 api from '../lib/api';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { RoomLayoutWizard } from '../components/layout/RoomLayoutWizard';
export default function RoomsPage() { export default function RoomsPage() {
const { isManager } = usePermissions(); const { isManager } = usePermissions();
const [rooms, setRooms] = useState<any[]>([]); const [rooms, setRooms] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isWizardOpen, setIsWizardOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchRooms(); fetchRooms();
@ -59,10 +61,19 @@ export default function RoomsPage() {
</div> </div>
{isManager && ( {isManager && (
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"> <div className="flex gap-3">
<Plus size={18} /> <button
Add Zone onClick={() => setIsWizardOpen(true)}
</button> className="flex items-center gap-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/50 px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
>
<Wand2 size={18} />
Generate Zone
</button>
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
<Plus size={18} />
Add Zone
</button>
</div>
)} )}
</div> </div>
@ -122,9 +133,17 @@ export default function RoomsPage() {
<Home size={40} className="mx-auto text-slate-300 dark:text-slate-700 mb-4" /> <Home size={40} className="mx-auto text-slate-300 dark:text-slate-700 mb-4" />
<p className="text-sm font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-4">No Cultivation Zones Configured</p> <p className="text-sm font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-4">No Cultivation Zones Configured</p>
{isManager && ( {isManager && (
<button className="btn btn-primary mt-4"> <div className="flex gap-4 justify-center">
<Plus size={16} /> Create First Zone <button
</button> onClick={() => setIsWizardOpen(true)}
className="flex items-center gap-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/50 px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
>
<Wand2 size={16} /> Generate Zone
</button>
<button className="btn btn-primary mt-4">
<Plus size={16} /> Create Custom
</button>
</div>
)} )}
</div> </div>
) : ( ) : (
@ -205,7 +224,13 @@ export default function RoomsPage() {
})} })}
</div> </div>
)} )}
<RoomLayoutWizard
isOpen={isWizardOpen}
onClose={() => setIsWizardOpen(false)}
onSuccess={fetchRooms}
floorId="default-floor-id"
/>
</div> </div>
); );
} }