diff --git a/frontend/src/components/facility3d/SmartRack.tsx b/frontend/src/components/facility3d/SmartRack.tsx index 41a7e36..844c2e8 100644 --- a/frontend/src/components/facility3d/SmartRack.tsx +++ b/frontend/src/components/facility3d/SmartRack.tsx @@ -4,9 +4,7 @@ import * as THREE from 'three'; import type { Section3D, Position3D } from '../../lib/layoutApi'; import { PlantPosition, VisMode } from './types'; import { PlantSystem } from './PlantSystem'; - -// Convert pixel coordinates to world units -const SCALE = 0.1; +import { SCALE } from './coordinates'; // Hierarchy context passed down from FacilityScene export interface HierarchyContext { @@ -34,18 +32,17 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim height: section.height * SCALE, }; - // Calculate how plants should be positioned within the section + // Calculate grid dimensions and spacing + const maxCols = Math.max(...section.positions.map(p => p.column), 1); + const maxRows = Math.max(...section.positions.map(p => p.row), 1); + const colSpacing = scaledSection.width / (maxCols + 1); + const rowSpacing = scaledSection.height / (maxRows + 1); + + // Calculate plant positions using consistent grid logic const positions: PlantPosition[] = useMemo(() => { - const maxCols = Math.max(...section.positions.map(p => p.column), 1); - const maxRows = Math.max(...section.positions.map(p => p.row), 1); - - // Calculate spacing to fit plants within section bounds - const colSpacing = scaledSection.width / (maxCols + 1); - const rowSpacing = scaledSection.height / (maxRows + 1); - return section.positions.map((pos: Position3D) => ({ ...pos, - // Position plants WITHIN the section + // Grid-based positioning within section (RAW coords, no floor centering here) x: scaledSection.posX + (pos.column * colSpacing), z: scaledSection.posY + (pos.row * rowSpacing), y: 0.4 + (pos.tier * 0.6), @@ -59,18 +56,12 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim tier: pos.tier, }, })); - }, [section, scaledSection, hierarchy]); + }, [section, scaledSection.posX, scaledSection.posY, colSpacing, rowSpacing, hierarchy]); const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b); const distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b); const distinctCols = [...new Set(positions.map(p => p.column))].sort((a, b) => a - b); - // Use calculated spacing for labels - const maxCols = Math.max(...section.positions.map(p => p.column), 1); - const maxRows = Math.max(...section.positions.map(p => p.row), 1); - const colSpacing = scaledSection.width / (maxCols + 1); - const rowSpacing = scaledSection.height / (maxRows + 1); - return ( {/* Section/Table Label */} diff --git a/frontend/src/components/facility3d/coordinates.ts b/frontend/src/components/facility3d/coordinates.ts new file mode 100644 index 0000000..0944e09 --- /dev/null +++ b/frontend/src/components/facility3d/coordinates.ts @@ -0,0 +1,71 @@ +import type { Section3D, Position3D } from '../../lib/layoutApi'; + +// Convert pixel coordinates to world units +export const SCALE = 0.1; + +/** + * Calculate the world coordinates (x, y, z) for a plant position within a section. + * This is the SINGLE SOURCE OF TRUTH for plant positioning in 3D space. + * + * @param section - The section containing the position + * @param position - The position within the section (row, column, tier) + * @param floorCenterX - Half of floor width in world units for centering + * @param floorCenterZ - Half of floor height in world units for centering + */ +export function calculatePlantWorldPosition( + section: Section3D, + position: { row: number; column: number; tier: number }, + floorCenterX: number = 0, + floorCenterZ: number = 0 +): { x: number; y: number; z: number } { + // Scale section to world units + const scaledSection = { + posX: section.posX * SCALE, + posY: section.posY * SCALE, + width: section.width * SCALE, + height: section.height * SCALE, + }; + + // Calculate grid dimensions from section positions + const maxCols = Math.max(...section.positions.map(p => p.column), 1); + const maxRows = Math.max(...section.positions.map(p => p.row), 1); + + // Calculate spacing to fit plants within section bounds + const colSpacing = scaledSection.width / (maxCols + 1); + const rowSpacing = scaledSection.height / (maxRows + 1); + + // Calculate raw position (before floor centering) + const rawX = scaledSection.posX + (position.column * colSpacing); + const rawZ = scaledSection.posY + (position.row * rowSpacing); + const y = 0.4 + (position.tier * 0.6); + + // Apply floor centering offset (scene is centered around 0,0) + const x = rawX - floorCenterX; + const z = rawZ - floorCenterZ; + + return { x, y, z }; +} + +/** + * Calculate floor center offsets from floor dimensions + */ +export function calculateFloorCenter(floorWidth: number, floorHeight: number): { centerX: number; centerZ: number } { + return { + centerX: (floorWidth * SCALE) / 2, + centerZ: (floorHeight * SCALE) / 2, + }; +} + +/** + * Find a section within floor data by searching all rooms + */ +export function findSectionInFloorData( + floorData: { rooms: Array<{ sections: Section3D[] }> }, + sectionId: string +): Section3D | undefined { + for (const room of floorData.rooms) { + const section = room.sections.find(s => s.id === sectionId); + if (section) return section; + } + return undefined; +} diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index 0fc09c1..ae29d8d 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, Suspense, Component, ReactNode, useCallback } from 'react'; import { Canvas } from '@react-three/fiber'; import { Html, CameraControls } from '@react-three/drei'; -import { layoutApi, Floor3DData, Room3D, Position3D } from '../lib/layoutApi'; +import { layoutApi, Floor3DData, Room3D, Position3D, Section3D } from '../lib/layoutApi'; import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf, Eye, EyeOff, Clock } from 'lucide-react'; import { Link, useSearchParams } from 'react-router-dom'; import { FacilityScene } from '../components/facility3d/FacilityScene'; @@ -12,6 +12,49 @@ import { TimelineSlider } from '../components/facility3d/TimelineSlider'; import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types'; import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets'; import { HierarchyBreadcrumb } from '../components/facility3d/HierarchyBreadcrumb'; +import { SCALE } from '../components/facility3d/coordinates'; + +/** + * Calculate plant world coordinates matching SmartRack's grid logic exactly. + * This ensures beacon position matches where plants are actually rendered. + */ +function calculatePlantCoords( + section: Section3D, + position: { row: number; column: number; tier: number }, + floorWidth: number, + floorHeight: number +): { x: number; y: number; z: number } { + // Scale section to world units (same as SmartRack) + const scaledSection = { + posX: section.posX * SCALE, + posY: section.posY * SCALE, + width: section.width * SCALE, + height: section.height * SCALE, + }; + + // Calculate grid dimensions (same as SmartRack) + const maxCols = Math.max(...section.positions.map(p => p.column), 1); + const maxRows = Math.max(...section.positions.map(p => p.row), 1); + + // Calculate spacing (same as SmartRack) + const colSpacing = scaledSection.width / (maxCols + 1); + const rowSpacing = scaledSection.height / (maxRows + 1); + + // Raw position within section (same as SmartRack) + const rawX = scaledSection.posX + (position.column * colSpacing); + const rawZ = scaledSection.posY + (position.row * rowSpacing); + const y = 0.4 + (position.tier * 0.6); + + // Apply floor centering (matches FacilityScene offset group) + const floorCenterX = (floorWidth * SCALE) / 2; + const floorCenterZ = (floorHeight * SCALE) / 2; + + return { + x: rawX - floorCenterX, + y, + z: rawZ - floorCenterZ, + }; +} // --- Error Boundary --- interface ErrorBoundaryState { hasError: boolean; error: Error | null; } @@ -82,10 +125,12 @@ export default function Facility3DViewerPage() { for (const section of room.sections) { const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag); if (match) { - const SCALE = 0.1; - const x = (room.posX + section.posX + (match.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2); - const z = (room.posY + section.posY + (match.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2); - const y = 0.4 + (match.tier * 0.6); + const { x, y, z } = calculatePlantCoords( + section, + { row: match.row, column: match.column, tier: match.tier }, + floorData.floor.width, + floorData.floor.height + ); const plant: PlantPosition = { ...match, x, y, z }; setSelectedPlant(plant); @@ -188,10 +233,17 @@ export default function Facility3DViewerPage() { const handleSearchSelect = useCallback((result: any) => { if (!floorData) return; - const SCALE = 0.1; - const x = (result.position.roomX + result.position.sectionX + (result.position.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2); - const z = (result.position.roomY + result.position.sectionY + (result.position.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2); - const y = 0.4 + (result.position.tier * 0.6); + // Find the actual section to use grid-based position calculation + const room = floorData.rooms.find(r => r.name === result.roomName); + const section = room?.sections.find(s => (s.code || s.name) === result.sectionCode); + if (!section) return; + + const { x, y, z } = calculatePlantCoords( + section, + { row: result.position.row, column: result.position.column, tier: result.position.tier }, + floorData.floor.width, + floorData.floor.height + ); setFocusTarget({ x, z }); setCameraPreset('ISOMETRIC'); @@ -342,14 +394,16 @@ export default function Facility3DViewerPage() { { - // Calculate position and select plant + // Find section and calculate position using same logic as SmartRack const room = floorData.rooms.find(r => r.name === roomName); const section = room?.sections.find(s => (s.code || s.name) === sectionCode); - if (room && section) { - const SCALE = 0.1; - const x = (room.posX + section.posX + (plant.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2); - const z = (room.posY + section.posY + (plant.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2); - const y = 0.4 + (plant.tier * 0.6); + if (section) { + const { x, y, z } = calculatePlantCoords( + section, + { row: plant.row, column: plant.column, tier: plant.tier }, + floorData.floor.width, + floorData.floor.height + ); setFocusTarget({ x, z }); setCameraPreset('ISOMETRIC');