feat: 3d viewer improvements and realistic seed data
This commit is contained in:
parent
7e8e070d11
commit
1701a046f6
4 changed files with 336 additions and 36 deletions
36
INSTRUCTIONS_3D_VIEWER.md
Normal file
36
INSTRUCTIONS_3D_VIEWER.md
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue