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"
|
"seed:all": "npx prisma db seed && node prisma/seed-demo.js"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "node prisma/seed.js"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/jwt": "^7.2.4",
|
"@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();
|
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
|
// Create Default Supplies
|
||||||
const 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.');
|
console.log('Seeding complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useEffect, useState, Suspense, useMemo, Component, ReactNode } from 'react';
|
import { useEffect, useState, Suspense, useMemo, Component, ReactNode, useRef } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls, Text, Instances, Instance, Html } from '@react-three/drei';
|
import { Text, Instances, Instance, Html, CameraControls } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { layoutApi, Floor3DData } from '../lib/layoutApi';
|
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';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
// Colors
|
// 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
|
// Process data into flat list of objects for rendering
|
||||||
const roomMeshes = useMemo(() => {
|
const roomMeshes = useMemo(() => {
|
||||||
return data.rooms.map(room => {
|
return data.rooms.map(room => {
|
||||||
|
|
@ -122,7 +187,7 @@ function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPla
|
||||||
<Text
|
<Text
|
||||||
position={[room.width / 2, 0.1, room.height / 2]}
|
position={[room.width / 2, 0.1, room.height / 2]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
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"
|
color="white"
|
||||||
fillOpacity={0.8}
|
fillOpacity={0.8}
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
|
|
@ -164,8 +229,12 @@ function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPla
|
||||||
<group key={section.id}>
|
<group key={section.id}>
|
||||||
{/* Section Label - Lifted higher */}
|
{/* Section Label - Lifted higher */}
|
||||||
<Text
|
<Text
|
||||||
position={[section.posX, 3, section.posY]}
|
position={[
|
||||||
fontSize={0.5}
|
section.posX + (section.width / 2),
|
||||||
|
3,
|
||||||
|
section.posY + (section.height / 2)
|
||||||
|
]}
|
||||||
|
fontSize={0.8}
|
||||||
color="#9ca3af"
|
color="#9ca3af"
|
||||||
>
|
>
|
||||||
{section.code}
|
{section.code}
|
||||||
|
|
@ -189,16 +258,17 @@ function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPla
|
||||||
{roomMeshes}
|
{roomMeshes}
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
{/* Improved Controls: Allow panning, damping for smoothness */}
|
{/* Changed from OrbitControls to CameraControls for better programmatic control */}
|
||||||
<OrbitControls
|
<CameraControls
|
||||||
|
ref={controlsRef}
|
||||||
|
minDistance={5}
|
||||||
|
maxDistance={150}
|
||||||
|
dollySpeed={1}
|
||||||
|
truckSpeed={2}
|
||||||
|
infinityDolly={false}
|
||||||
makeDefault
|
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]} />
|
<gridHelper args={[200, 100, 0x444444, 0x111111]} position={[0, -0.1, 0]} />
|
||||||
<axesHelper args={[5]} />
|
<axesHelper args={[5]} />
|
||||||
</>
|
</>
|
||||||
|
|
@ -209,6 +279,8 @@ export default function Facility3DViewerPage() {
|
||||||
const [status, setStatus] = useState('Initializing...');
|
const [status, setStatus] = useState('Initializing...');
|
||||||
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
||||||
const [selectedPlant, setSelectedPlant] = useState<any | 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(() => {
|
useEffect(() => {
|
||||||
loadData();
|
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 (
|
return (
|
||||||
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden">
|
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden">
|
||||||
{/* Header Overlay */}
|
{/* Header Overlay */}
|
||||||
|
|
@ -248,7 +338,6 @@ export default function Facility3DViewerPage() {
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{floorData ? `${floorData.floor.name} • ${floorData.stats.occupiedPositions} Plants` : 'Loading...'}
|
{floorData ? `${floorData.floor.name} • ${floorData.stats.occupiedPositions} Plants` : 'Loading...'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-gray-500">Controls: Left Click=Rotate, Right Click=Pan, Scroll=Zoom</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -261,6 +350,43 @@ export default function Facility3DViewerPage() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Error/Status Overlay */}
|
||||||
{status && (
|
{status && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/80 backdrop-blur-sm">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Canvas camera={{ position: [20, 30, 20], fov: 60 }}>
|
<Canvas camera={{ position: [20, 30, 20], fov: 60 }} shadows>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
|
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
|
||||||
{floorData && (
|
{floorData && (
|
||||||
<FacilityScene
|
<FacilityScene
|
||||||
data={floorData}
|
data={floorData}
|
||||||
onSelectPlant={setSelectedPlant}
|
onSelectPlant={setSelectedPlant}
|
||||||
|
targetView={targetView}
|
||||||
|
setControls={setControls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue