feat: 3d viewer improvements and realistic seed data
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-17 16:10:14 -08:00
parent 7e8e070d11
commit 1701a046f6
4 changed files with 336 additions and 36 deletions

36
INSTRUCTIONS_3D_VIEWER.md Normal file
View file

@ -0,0 +1,36 @@
# 3D Facility Viewer Updates
I've updated the 3D Viewer to improve navigation and usability, and fixed the issue where plants were floating in the void.
## 🚀 Enhancements Added
1. **Quick Navigation Sidebar**: A list of rooms is now displayed on the left. Clicking a room name smoothly flies the camera to that location.
2. **WASD Controls**: You can now use `W`, `A`, `S`, `D` (and arrow keys) to move the camera around the facility.
3. **Smooth Camera Transitions**: Replaced standard controls with `CameraControls` for cinematic transitions between rooms.
4. **Reset View**: Added a button to reset the view to the facility center.
## ⚠️ Important: Fix "Floating Plants" Data
The issue where plants appeared far away from rooms was caused by missing coordinate data in the database. I have updated the seed script to generate realistic 3D layout data.
**You must re-seed the database for the 3D view to look correct.**
Run the following command on your deployment server (or locally if you have the DB running):
```bash
# If running via Docker Compose (Recommended)
docker compose exec backend npm run seed
```
Or if you are running the backend locally with a `.env` file containing `DATABASE_URL`:
```bash
cd backend
npm run seed
```
This will populate the database with:
- Spatially aware Rooms (Veg, Flower, Dry)
- Correctly positioned Racks and Benches
- Plants placed inside those racks

View file

@ -13,7 +13,7 @@
"seed:all": "npx prisma db seed && node prisma/seed-demo.js"
},
"prisma": {
"seed": "node prisma/seed.js"
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@fastify/jwt": "^7.2.4",

View file

@ -1,4 +1,4 @@
import { PrismaClient, Role, RoomType } from '@prisma/client';
import { PrismaClient, RoomType, SectionType } from '@prisma/client';
const prisma = new PrismaClient();
@ -75,22 +75,6 @@ async function main() {
}
}
// Create Default Rooms
const rooms = [
{ name: 'Veg Room 1', type: RoomType.VEG, sqft: 1200 },
{ name: 'Flower Room A', type: RoomType.FLOWER, sqft: 2500 },
{ name: 'Flower Room B', type: RoomType.FLOWER, sqft: 2500 },
{ name: 'Dry Room', type: RoomType.DRY, sqft: 800 },
];
for (const r of rooms) {
const existing = await prisma.room.findFirst({ where: { name: r.name } });
if (!existing) {
await prisma.room.create({ data: r });
console.log(`Created Room: ${r.name}`);
}
}
// Create Default Supplies
const supplies = [
{
@ -145,6 +129,158 @@ async function main() {
}
}
// ============================================
// FACILITY 3D LAYOUT (New)
// ============================================
// 1. Property
let property = await prisma.facilityProperty.findFirst();
if (!property) {
property = await prisma.facilityProperty.create({
data: {
name: 'Wolfpack Facility',
address: '123 Grow St',
licenseNum: 'CML-123456'
}
});
console.log('Created Facility Property');
}
// 2. Building
let building = await prisma.facilityBuilding.findFirst({ where: { propertyId: property.id } });
if (!building) {
building = await prisma.facilityBuilding.create({
data: {
propertyId: property.id,
name: 'Main Building',
code: 'MAIN',
type: 'CULTIVATION'
}
});
console.log('Created Facility Building');
}
// 3. Floor
let floor = await prisma.facilityFloor.findFirst({ where: { buildingId: building.id } });
if (!floor) {
floor = await prisma.facilityFloor.create({
data: {
buildingId: building.id,
name: 'Ground Floor',
number: 1,
width: 100, // 100x100 grid
height: 100
}
});
console.log('Created Facility Floor');
}
// 4. Rooms (Spatial)
const spatialRooms = [
{
name: 'Veg Room', code: 'VEG', type: RoomType.VEG,
posX: 5, posY: 5, width: 30, height: 40, color: '#4ade80',
sections: [
{ name: 'Rack A', code: 'A', rows: 4, columns: 8, spacing: 2, posX: 2, posY: 2 },
{ name: 'Rack B', code: 'B', rows: 4, columns: 8, spacing: 2, posX: 15, posY: 2 }
]
},
{
name: 'Flower Room', code: 'FLO', type: RoomType.FLOWER,
posX: 40, posY: 5, width: 50, height: 60, color: '#a855f7',
sections: [
{ name: 'Bench 1', code: 'B1', rows: 10, columns: 4, spacing: 2, posX: 5, posY: 5 },
{ name: 'Bench 2', code: 'B2', rows: 10, columns: 4, spacing: 2, posX: 20, posY: 5 },
{ name: 'Bench 3', code: 'B3', rows: 10, columns: 4, spacing: 2, posX: 35, posY: 5 }
]
},
{
name: 'Dry Room', code: 'DRY', type: RoomType.DRY,
posX: 5, posY: 50, width: 25, height: 25, color: '#f59e0b',
sections: [
{ name: 'Hangers', code: 'H1', rows: 2, columns: 10, spacing: 1, posX: 2, posY: 2 }
]
}
];
for (const r of spatialRooms) {
let room = await prisma.facilityRoom.findFirst({
where: { floorId: floor.id, code: r.code }
});
if (!room) {
room = await prisma.facilityRoom.create({
data: {
floorId: floor.id,
name: r.name,
code: r.code,
type: r.type,
posX: r.posX,
posY: r.posY,
width: r.width,
height: r.height,
color: r.color
}
});
console.log(`Created Spatial Room: ${r.name}`);
} else {
await prisma.facilityRoom.update({
where: { id: room.id },
data: {
posX: r.posX,
posY: r.posY,
width: r.width,
height: r.height,
color: r.color
}
});
console.log(`Updated Spatial Room Coords: ${r.name}`);
}
// Sections
for (const s of r.sections) {
let section = await prisma.facilitySection.findFirst({
where: { roomId: room.id, code: s.code }
});
if (!section) {
// Create section & positions
const positions = [];
for (let row = 1; row <= s.rows; row++) {
for (let col = 1; col <= s.columns; col++) {
positions.push({ row, column: col, tier: 1, slot: 1 });
}
}
section = await prisma.facilitySection.create({
data: {
roomId: room.id,
name: s.name,
code: s.code,
type: SectionType.RACK,
posX: s.posX,
posY: s.posY,
width: s.columns * s.spacing,
height: s.rows * s.spacing,
rows: s.rows,
columns: s.columns,
spacing: s.spacing,
positions: {
create: positions
}
}
});
console.log(`Created Section: ${s.name} in ${r.name}`);
} else {
// Update section pos
await prisma.facilitySection.update({
where: { id: section.id },
data: { posX: s.posX, posY: s.posY }
});
}
}
}
console.log('Seeding complete.');
}

View file

@ -1,9 +1,9 @@
import { useEffect, useState, Suspense, useMemo, Component, ReactNode } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Text, Instances, Instance, Html } from '@react-three/drei';
import { useEffect, useState, Suspense, useMemo, Component, ReactNode, useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Text, Instances, Instance, Html, CameraControls } from '@react-three/drei';
import * as THREE from 'three';
import { layoutApi, Floor3DData } from '../lib/layoutApi';
import { Loader2, ArrowLeft } from 'lucide-react';
import { Loader2, ArrowLeft, Maximize, MousePointer2 } from 'lucide-react';
import { Link } from 'react-router-dom';
// Colors
@ -111,7 +111,72 @@ function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlant
);
}
function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPlant: (p: any) => void }) {
function FacilityScene({ data, onSelectPlant, targetView, setControls }: {
data: Floor3DData,
onSelectPlant: (p: any) => void,
targetView: { x: number, y: number, z: number, zoom?: boolean } | null,
setControls: (c: CameraControls | null) => void
}) {
const controlsRef = useRef<CameraControls>(null);
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
// Register controls
useEffect(() => {
if (controlsRef.current) {
setControls(controlsRef.current);
}
}, [controlsRef, setControls]);
// Handle View Navigation
useEffect(() => {
if (targetView && controlsRef.current) {
const { x, y, z, zoom } = targetView;
// Smoothly move camera to look at the target room
// Position camera slightly offset from the target for a good view
const dist = zoom ? 15 : 40;
const height = zoom ? 15 : 40;
controlsRef.current.setLookAt(
x + dist, height, z + dist, // Camera Position
x, 0, z, // Target Position
true // Enable transition
);
}
}, [targetView]);
// Keyboard Controls Logic
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: true }));
const handleKeyUp = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: false }));
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
useFrame((_, delta) => {
if (!controlsRef.current) return;
const speed = 20 * delta; // Movement speed
// WASD / Arrow Keys Navigation
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);
}
});
// Process data into flat list of objects for rendering
const roomMeshes = useMemo(() => {
return data.rooms.map(room => {
@ -122,7 +187,7 @@ function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPla
<Text
position={[room.width / 2, 0.1, room.height / 2]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={1.5} // Larger text
fontSize={Math.min(room.width, room.height) / 5} // Scale text to room
color="white"
fillOpacity={0.8}
anchorX="center"
@ -164,8 +229,12 @@ function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPla
<group key={section.id}>
{/* Section Label - Lifted higher */}
<Text
position={[section.posX, 3, section.posY]}
fontSize={0.5}
position={[
section.posX + (section.width / 2),
3,
section.posY + (section.height / 2)
]}
fontSize={0.8}
color="#9ca3af"
>
{section.code}
@ -189,16 +258,17 @@ function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPla
{roomMeshes}
</group>
{/* Improved Controls: Allow panning, damping for smoothness */}
<OrbitControls
{/* Changed from OrbitControls to CameraControls for better programmatic control */}
<CameraControls
ref={controlsRef}
minDistance={5}
maxDistance={150}
dollySpeed={1}
truckSpeed={2}
infinityDolly={false}
makeDefault
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={2}
maxDistance={100}
dampingFactor={0.05}
/>
<gridHelper args={[200, 100, 0x444444, 0x111111]} position={[0, -0.1, 0]} />
<axesHelper args={[5]} />
</>
@ -209,6 +279,8 @@ export default function Facility3DViewerPage() {
const [status, setStatus] = useState('Initializing...');
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
const [selectedPlant, setSelectedPlant] = useState<any | null>(null);
const [targetView, setTargetView] = useState<{ x: number, y: number, z: number, zoom?: boolean } | null>(null);
const [controls, setControls] = useState<CameraControls | null>(null);
useEffect(() => {
loadData();
@ -232,6 +304,24 @@ export default function Facility3DViewerPage() {
}
}
const focusRoom = (room: any) => {
// Calculate room center relative to the scene group offset
// Scene group is offset by [-floor.width/2, 0, -floor.height/2]
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 });
};
const resetView = () => {
setTargetView({ x: 0, y: 0, z: 0, zoom: false });
};
return (
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden">
{/* Header Overlay */}
@ -248,7 +338,6 @@ export default function Facility3DViewerPage() {
<p className="text-xs text-gray-400">
{floorData ? `${floorData.floor.name}${floorData.stats.occupiedPositions} Plants` : 'Loading...'}
</p>
<p className="text-[10px] text-gray-500">Controls: Left Click=Rotate, Right Click=Pan, Scroll=Zoom</p>
</div>
</div>
@ -261,6 +350,43 @@ export default function Facility3DViewerPage() {
</div>
</div>
{/* Quick Nav Sidebar */}
{floorData && (
<div className="absolute top-24 left-4 z-10 w-48 space-y-2 pointer-events-auto animate-in slide-in-from-left-4">
<div className="bg-black/40 backdrop-blur rounded-lg p-3 border border-gray-800">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">Navigation</span>
<button onClick={resetView} className="hover:text-accent transition-colors">
<Maximize size={14} />
</button>
</div>
<div className="space-y-1 max-h-[60vh] overflow-y-auto">
{floorData.rooms.map(room => (
<button
key={room.id}
onClick={() => focusRoom(room)}
className="w-full text-left text-sm px-2 py-1.5 rounded hover:bg-gray-800 transition-colors flex items-center justify-between group"
>
<span className="truncate">{room.name}</span>
<MousePointer2 size={12} className="opacity-0 group-hover:opacity-100 text-accent transition-opacity" />
</button>
))}
</div>
</div>
{/* Controls Help */}
<div className="bg-black/40 backdrop-blur rounded-lg p-3 border border-gray-800 text-xs text-gray-500">
<div className="font-bold mb-1 text-gray-400">Controls</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1">
<span>Rotate</span> <span className="text-right text-gray-300">Left Click</span>
<span>Pan</span> <span className="text-right text-gray-300">Right Click</span>
<span>Zoom</span> <span className="text-right text-gray-300">Scroll</span>
<span>Move</span> <span className="text-right text-gray-300">W A S D</span>
</div>
</div>
</div>
)}
{/* Error/Status Overlay */}
{status && (
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/80 backdrop-blur-sm">
@ -309,13 +435,15 @@ export default function Facility3DViewerPage() {
</div>
)}
<Canvas camera={{ position: [20, 30, 20], fov: 60 }}>
<Canvas camera={{ position: [20, 30, 20], fov: 60 }} shadows>
<ErrorBoundary>
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
{floorData && (
<FacilityScene
data={floorData}
onSelectPlant={setSelectedPlant}
targetView={targetView}
setControls={setControls}
/>
)}
</Suspense>