From bfe7a2932de5554a103efa74e58cdfa9abf051ee Mon Sep 17 00:00:00 2001 From: soovittt Date: Tue, 2 Dec 2025 15:22:52 -0800 Subject: [PATCH] feat: Add delete asset functionality with tooltip UI - Add tooltip that appears when clicking on placed objects/agents - Display object info (type, position) in tooltip - Add delete button in tooltip to remove assets from environment - Support closing tooltip via click outside or Escape key - Update click handlers to pass event coordinates for tooltip positioning - Improve tooltip styling with solid background and better visibility Fixes #111 --- app/components/GridCanvasThree.tsx | 510 ++++++++++++++++++++--------- 1 file changed, 353 insertions(+), 157 deletions(-) diff --git a/app/components/GridCanvasThree.tsx b/app/components/GridCanvasThree.tsx index 318f708..cd4b1e5 100644 --- a/app/components/GridCanvasThree.tsx +++ b/app/components/GridCanvasThree.tsx @@ -27,7 +27,7 @@ interface GridCanvasThreeProps { interface SceneContentProps { envSpec: EnvSpec rolloutState?: { agents: Array<{ id: string; position: Vec2 }> } - onCellClick: (x: number, y: number) => void + onCellClick: (x: number, y: number, event?: { clientX: number; clientY: number }) => void onCellRightClick: (e: any, x: number, y: number) => void selectedObjectId?: string selectedAgentId?: string @@ -50,30 +50,27 @@ const TOOL_PALETTE: Array<{ type: ObjectType | 'agent'; label: string; color: st function hexToRgb(hex: string): [number, number, number] { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return result - ? [ - parseInt(result[1], 16) / 255, - parseInt(result[2], 16) / 255, - parseInt(result[3], 16) / 255, - ] + ? [parseInt(result[1], 16) / 255, parseInt(result[2], 16) / 255, parseInt(result[3], 16) / 255] : [0.5, 0.5, 0.5] } // Grid Cell Component - Memoized for performance -const GridCell = memo(function GridCell({ - x, y, - object, - agent, +const GridCell = memo(function GridCell({ + x, + y, + object, + agent, isSelected, onClick, onRightClick, - assets = [] + assets = [], }: { x: number y: number object: ObjectSpec | null agent: { id: string; position: Vec2 } | null isSelected: boolean - onClick: () => void + onClick: (event?: { clientX: number; clientY: number }) => void onRightClick: (e: any) => void assets?: Asset[] }) { @@ -85,11 +82,11 @@ const GridCell = memo(function GridCell({ // Try to find asset by assetId stored in properties let asset: Asset | undefined if (object.properties?.assetId && assets && Array.isArray(assets) && assets.length > 0) { - asset = assets.find(a => a._id === object.properties.assetId) + asset = assets.find((a) => a._id === object.properties.assetId) } // Fallback: find asset by type if (!asset && assets && Array.isArray(assets) && assets.length > 0) { - asset = assets.find(a => { + asset = assets.find((a) => { const objectType = assetToObjectType(a) return objectType === object.type }) @@ -107,12 +104,13 @@ const GridCell = memo(function GridCell({ } } else if (agent) { // Find agent asset - const agentAsset = (assets && Array.isArray(assets) && assets.length > 0) - ? assets.find(a => { - const objectType = assetToObjectType(a) - return objectType === 'agent' - }) - : undefined + const agentAsset = + assets && Array.isArray(assets) && assets.length > 0 + ? assets.find((a) => { + const objectType = assetToObjectType(a) + return objectType === 'agent' + }) + : undefined if (agentAsset) { const hexColor = agentAsset.meta?.paletteColor || agentAsset.visualProfile?.color || '#4a90e2' color = hexToRgb(hexColor) @@ -125,7 +123,15 @@ const GridCell = memo(function GridCell({ const handleClick = (e: any) => { e.stopPropagation() - onClick() + // Pass event coordinates for tooltip positioning + // React Three Fiber events have different structure + const event = e.nativeEvent || (e.domEvent ? e.domEvent : e) + if (event && typeof event.clientX === 'number' && typeof event.clientY === 'number') { + onClick(event) + } else { + // Fallback: try to get coordinates from the event + onClick() + } } const handleRightClick = (e: any) => { @@ -165,24 +171,20 @@ const GridCell = memo(function GridCell({ ) } - + if (object) { const objectType = object.type - + if (objectType === 'wall') { // Wall: Tall 3D box return ( - + ) } - + if (objectType === 'goal') { // Goal: Cylinder with glowing top return ( @@ -199,16 +201,12 @@ const GridCell = memo(function GridCell({ - + ) } - + if (objectType === 'key' || objectType === 'pickup') { // Key/Pickup: Small 3D shape on pedestal return ( @@ -230,18 +228,14 @@ const GridCell = memo(function GridCell({ ) } - + if (objectType === 'door') { // Door: Tall box with frame return ( - + @@ -250,31 +244,23 @@ const GridCell = memo(function GridCell({ ) } - + if (objectType === 'trap' || objectType === 'hazard') { // Trap/Hazard: Spiky shape return ( - + - + ) } - + // Default: Low box for other objects return ( @@ -289,7 +275,7 @@ const GridCell = memo(function GridCell({ ) } - + // Empty cell: just floor return null } @@ -297,7 +283,15 @@ const GridCell = memo(function GridCell({ return ( { + // Get DOM event from React Three Fiber event + const domEvent = e.nativeEvent || (e as any).domEvent + if (domEvent) { + handleClick(domEvent) + } else { + handleClick() + } + }} onContextMenu={handleRightClick} onPointerOver={(e) => { e.stopPropagation() @@ -316,7 +310,7 @@ const GridCell = memo(function GridCell({ roughness={0.8} /> - + {/* Visible grid cell borders - raised edges */} {/* Top edge */} @@ -340,10 +334,10 @@ const GridCell = memo(function GridCell({ - + {/* 3D object/agent */} {get3DShape()} - + {/* Selection indicator */} {isSelected && ( @@ -363,18 +357,18 @@ const GridCell = memo(function GridCell({ }) // Scene Content - Memoized for performance -function SceneContentInner({ - envSpec, +function SceneContentInner({ + envSpec, rolloutState, onCellClick, onCellRightClick, selectedObjectId, selectedAgentId, - assets + assets, }: { envSpec: EnvSpec rolloutState?: { agents: Array<{ id: string; position: Vec2 }> } - onCellClick: (x: number, y: number) => void + onCellClick: (x: number, y: number, event?: { clientX: number; clientY: number }) => void onCellRightClick: (e: any, x: number, y: number) => void selectedObjectId?: string selectedAgentId?: string @@ -389,10 +383,10 @@ function SceneContentInner({ if (!envSpec) { return [] } - + const objects = Array.isArray(envSpec.objects) ? envSpec.objects : [] const agents = Array.isArray(envSpec.agents) ? envSpec.agents : [] - + // Convert grid position to world coordinates const gridToWorld = (gridX: number, gridY: number): Vec2 => { return [gridX * cellSize, gridY * cellSize] @@ -401,59 +395,64 @@ function SceneContentInner({ // Get object at grid position const getObjectAt = (gridX: number, gridY: number): ObjectSpec | null => { const worldPos = gridToWorld(gridX, gridY) - return objects.find((obj) => { - if (!obj || !obj.position || !Array.isArray(obj.position)) return false - const [objX, objY] = obj.position - return Math.floor(objX) === Math.floor(worldPos[0]) && - Math.floor(objY) === Math.floor(worldPos[1]) - }) || null + return ( + objects.find((obj) => { + if (!obj || !obj.position || !Array.isArray(obj.position)) return false + const [objX, objY] = obj.position + return ( + Math.floor(objX) === Math.floor(worldPos[0]) && + Math.floor(objY) === Math.floor(worldPos[1]) + ) + }) || null + ) } // Get agent at grid position const getAgentAt = (gridX: number, gridY: number) => { const worldPos = gridToWorld(gridX, gridY) let agentsToCheck: Array<{ id: string; position: Vec2 }> = [] - + if (rolloutState?.agents && Array.isArray(rolloutState.agents)) { agentsToCheck = rolloutState.agents } else if (Array.isArray(agents)) { - agentsToCheck = agents.map(a => ({ id: a.id, position: a.position })) + agentsToCheck = agents.map((a) => ({ id: a.id, position: a.position })) } - + for (const agent of agentsToCheck) { if (!agent || !agent.position || !Array.isArray(agent.position)) continue const [agentX, agentY] = agent.position - + // Convert agent world position to grid coordinates - const agentGridX = Math.round(agentX / cellSize) // Use round instead of floor for better matching + const agentGridX = Math.round(agentX / cellSize) // Use round instead of floor for better matching const agentGridY = Math.round(agentY / cellSize) - + // Exact grid match if (agentGridX === gridX && agentGridY === gridY) { return agent } - + // Tolerance check (for floating point precision) const cellWorldX = worldPos[0] const cellWorldY = worldPos[1] - const tolerance = cellSize * 0.6 // Increased tolerance - if (Math.abs(agentX - cellWorldX) < tolerance && - Math.abs(agentY - cellWorldY) < tolerance) { + const tolerance = cellSize * 0.6 // Increased tolerance + if ( + Math.abs(agentX - cellWorldX) < tolerance && + Math.abs(agentY - cellWorldY) < tolerance + ) { return agent } } - + return null } - + const result: JSX.Element[] = [] for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const object = getObjectAt(x, y) const agent = getAgentAt(x, y) - const isSelected = - (object && selectedObjectId === object.id) || - (agent && selectedAgentId === agent.id) + const isSelected = + (object && selectedObjectId === object.id) || (agent && selectedAgentId === agent.id) // Center cells: cell at (0,0) should be at (-width/2 + 0.5, -height/2 + 0.5) result.push( @@ -464,7 +463,7 @@ function SceneContentInner({ object={object} agent={agent} isSelected={isSelected} - onClick={() => onCellClick(x, y)} + onClick={(event) => onCellClick(x, y, event)} onRightClick={(e) => onCellRightClick(e, x, y)} assets={assets} /> @@ -472,7 +471,18 @@ function SceneContentInner({ } } return result - }, [envSpec, rolloutState, selectedObjectId, selectedAgentId, width, height, cellSize, onCellClick, onCellRightClick, assets]) + }, [ + envSpec, + rolloutState, + selectedObjectId, + selectedAgentId, + width, + height, + cellSize, + onCellClick, + onCellRightClick, + assets, + ]) return ( <> @@ -521,22 +531,38 @@ function SceneContentInner({ const SceneContent = memo(SceneContentInner) -export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutState, selectedAssetId, onAssetSelect }: GridCanvasThreeProps) { +export function GridCanvasThree({ + envSpec, + sceneGraph, + onSpecChange, + rolloutState, + selectedAssetId, + onAssetSelect, +}: GridCanvasThreeProps) { const { selection, selectObject, selectAgent } = useSelection() // Declare assets state first before using it const [assets, setAssets] = useState([]) // Use external selectedAssetId if provided (from LayersPanel), otherwise use local state const [localSelectedAsset, setLocalSelectedAsset] = useState(null) - const selectedAsset = selectedAssetId - ? assets.find(a => a._id === selectedAssetId) || null + const selectedAsset = selectedAssetId + ? assets.find((a) => a._id === selectedAssetId) || null : localSelectedAsset const [selectedTool, setSelectedTool] = useState(null) const [showAssetSelector, setShowAssetSelector] = useState(false) - + + // Tooltip state for object info and delete button + const [tooltip, setTooltip] = useState<{ + visible: boolean + x: number + y: number + object?: ObjectSpec + agent?: { id: string; position: Vec2 } + }>({ visible: false, x: 0, y: 0 }) + // Use ref to persist asset selection across re-renders const selectedAssetRef = useRef(null) const selectedToolRef = useRef(null) - + // Sync refs with state useEffect(() => { selectedAssetRef.current = selectedAsset @@ -557,18 +583,18 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat try { const { listAssets } = await import('~/lib/assetClient') const loadedAssets = await listAssets({ mode: 'grid' }) - + console.log('🔍 GridCanvasThree: Loaded assets:', loadedAssets) - + // Ensure loadedAssets is an array if (!Array.isArray(loadedAssets)) { console.warn('⚠️ GridCanvasThree: Assets not loaded as array:', loadedAssets) setAssets([]) return } - + console.log(`📦 GridCanvasThree: Total assets: ${loadedAssets.length}`) - + // Show all grid assets - be very lenient const gridAssets = loadedAssets .filter((asset) => { @@ -583,15 +609,20 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat // Very lenient: include if has grid tag, grid mode, OR no mode restriction const result = tags.includes('grid') || assetMode === 'grid' || !assetMode if (!result) { - console.log(`⏭️ GridCanvasThree: Skipping ${asset.name} - tags: ${tags.join(', ')}, mode: ${assetMode}`) + console.log( + `⏭️ GridCanvasThree: Skipping ${asset.name} - tags: ${tags.join(', ')}, mode: ${assetMode}` + ) } return result }) .sort((a, b) => a.name.localeCompare(b.name)) - + console.log(`✅ GridCanvasThree: Filtered to ${gridAssets.length} grid assets`) - console.log('📋 GridCanvasThree: Assets:', gridAssets.map(a => a.name)) - + console.log( + '📋 GridCanvasThree: Assets:', + gridAssets.map((a) => a.name) + ) + setAssets(gridAssets) } catch (err) { console.error('❌ GridCanvasThree: Failed to load assets:', err) @@ -632,95 +663,134 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat const getObjectAt = (gridX: number, gridY: number): ObjectSpec | null => { const worldPos = gridToWorld(gridX, gridY) const objects = Array.isArray(envSpec.objects) ? envSpec.objects : [] - return objects.find((obj) => { - if (!obj || !obj.position || !Array.isArray(obj.position)) return false - const [objX, objY] = obj.position - return Math.floor(objX) === Math.floor(worldPos[0]) && - Math.floor(objY) === Math.floor(worldPos[1]) - }) || null + return ( + objects.find((obj) => { + if (!obj || !obj.position || !Array.isArray(obj.position)) return false + const [objX, objY] = obj.position + return ( + Math.floor(objX) === Math.floor(worldPos[0]) && + Math.floor(objY) === Math.floor(worldPos[1]) + ) + }) || null + ) } // Get agent at grid position const getAgentAt = (gridX: number, gridY: number) => { const worldPos = gridToWorld(gridX, gridY) let agentsToCheck: Array<{ id: string; position: Vec2 }> = [] - + if (rolloutState?.agents && Array.isArray(rolloutState.agents)) { agentsToCheck = rolloutState.agents } else { const agents = Array.isArray(envSpec.agents) ? envSpec.agents : [] - agentsToCheck = agents.map(a => ({ id: a.id, position: a.position })) + agentsToCheck = agents.map((a) => ({ id: a.id, position: a.position })) } - + for (const agent of agentsToCheck) { if (!agent || !agent.position || !Array.isArray(agent.position)) continue const [agentX, agentY] = agent.position - + // Convert agent world position to grid coordinates (use round for better matching) const agentGridX = Math.round(agentX / cellSize) const agentGridY = Math.round(agentY / cellSize) - + // Exact grid match if (agentGridX === gridX && agentGridY === gridY) { return agent } - + // Tolerance check (for floating point precision) const cellWorldX = worldPos[0] const cellWorldY = worldPos[1] - const tolerance = cellSize * 0.6 // Increased tolerance - if (Math.abs(agentX - cellWorldX) < tolerance && - Math.abs(agentY - cellWorldY) < tolerance) { + const tolerance = cellSize * 0.6 // Increased tolerance + if (Math.abs(agentX - cellWorldX) < tolerance && Math.abs(agentY - cellWorldY) < tolerance) { return agent } } - + return null } - const handleCellClick = (gridX: number, gridY: number) => { + const handleCellClick = ( + gridX: number, + gridY: number, + event?: { clientX: number; clientY: number } + ) => { if (rolloutState) return // Prevent editing during rollout const existingObject = getObjectAt(gridX, gridY) const existingAgent = getAgentAt(gridX, gridY) - // If clicking on existing object/agent, select it but KEEP asset selection for potential replacement + // If clicking on existing object/agent, show tooltip with info and delete button if (existingObject) { selectObject(existingObject.id) - // Don't clear asset selection - user might want to replace it or place more - console.log('📌 Selected existing object, asset selection persists:', selectedAsset?.name, 'selectedTool:', selectedTool) + // Show tooltip at click position + if (event) { + setTooltip({ + visible: true, + x: event.clientX, + y: event.clientY, + object: existingObject, + }) + } return } if (existingAgent) { selectAgent(existingAgent.id) - // Don't clear asset selection - user might want to replace it or place more - console.log('📌 Selected existing agent, asset selection persists:', selectedAsset?.name, 'selectedTool:', selectedTool) + // Show tooltip at click position + if (event) { + setTooltip({ + visible: true, + x: event.clientX, + y: event.clientY, + agent: existingAgent, + }) + } return } + // Hide tooltip when clicking empty cell + setTooltip({ visible: false, x: 0, y: 0 }) + // Place new object or agent using selected asset or hardcoded tool const worldPos = gridToWorld(gridX, gridY) - console.log('🎯 handleCellClick - selectedAsset:', selectedAsset?.name, 'selectedTool:', selectedTool) + console.log( + '🎯 handleCellClick - selectedAsset:', + selectedAsset?.name, + 'selectedTool:', + selectedTool + ) // Use selected tool (from hardcoded palette) or asset // Use refs to ensure we have the latest values even if state hasn't updated yet const currentSelectedTool = selectedToolRef.current || selectedTool const currentSelectedAsset = selectedAssetRef.current || selectedAsset - + let objectType: ObjectType | 'agent' | null = null if (currentSelectedTool) { objectType = currentSelectedTool console.log('✅ Using selectedTool:', currentSelectedTool) } else if (currentSelectedAsset) { objectType = assetToObjectType(currentSelectedAsset) as ObjectType - console.log('✅ Using selectedAsset, converted to objectType:', objectType, 'asset:', currentSelectedAsset.name) + console.log( + '✅ Using selectedAsset, converted to objectType:', + objectType, + 'asset:', + currentSelectedAsset.name + ) } if (!objectType) { // No asset/tool selected - open asset selector to help user - console.warn('⚠️ No objectType available - selectedAsset:', selectedAsset?.name, 'selectedTool:', selectedTool) + console.warn( + '⚠️ No objectType available - selectedAsset:', + selectedAsset?.name, + 'selectedTool:', + selectedTool + ) if (!selectedAsset && !selectedTool) { console.log('📂 Opening asset selector...') setShowAssetSelector(true) @@ -745,7 +815,12 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat currentSelectedAsset ? { assetId: currentSelectedAsset._id } : {} // Store asset reference if available ) // Asset selection persists - user can place multiple objects - console.log('✅ Placed object, selectedAsset still:', currentSelectedAsset?.name, 'state:', selectedAsset?.name) + console.log( + '✅ Placed object, selectedAsset still:', + currentSelectedAsset?.name, + 'state:', + selectedAsset?.name + ) } onSpecChange(sceneGraph.getSpec()) @@ -770,9 +845,56 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat } } + // Handle delete from tooltip + const handleDelete = () => { + if (tooltip.object) { + sceneGraph.removeObject(tooltip.object.id) + onSpecChange(sceneGraph.getSpec()) + setTooltip({ visible: false, x: 0, y: 0 }) + } else if (tooltip.agent) { + sceneGraph.removeAgent(tooltip.agent.id) + onSpecChange(sceneGraph.getSpec()) + setTooltip({ visible: false, x: 0, y: 0 }) + } + } + + // Close tooltip when clicking outside or pressing Escape + useEffect(() => { + if (!tooltip.visible) return + + const handleClickOutside = (e: MouseEvent) => { + const tooltipElement = document.querySelector('[data-tooltip]') + if (tooltipElement && !tooltipElement.contains(e.target as Node)) { + setTooltip({ visible: false, x: 0, y: 0 }) + } + } + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setTooltip({ visible: false, x: 0, y: 0 }) + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [tooltip.visible]) + // Debug: Log state on every render useEffect(() => { - console.log('🔄 GridCanvasThree render - selectedAsset:', selectedAsset?.name, 'selectedTool:', selectedTool, 'showAssetSelector:', showAssetSelector, 'assets.length:', assets.length) + console.log( + '🔄 GridCanvasThree render - selectedAsset:', + selectedAsset?.name, + 'selectedTool:', + selectedTool, + 'showAssetSelector:', + showAssetSelector, + 'assets.length:', + assets.length + ) }) // CRITICAL: Ensure button is always visible - add explicit check @@ -784,7 +906,12 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat return ( ) } @@ -832,8 +971,9 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat
Selected: {selectedAsset.name} - Click empty cells to place @@ -881,7 +1021,10 @@ export function GridCanvasThree({ envSpec, sceneGraph, onSpecChange, rolloutStat )} {/* 3D Canvas */} -
+
- + - Grid: {width} × {height} | Objects: {envSpec.objects.length} | Agents: {envSpec.agents.length} + Grid: {width} × {height} | Objects: {envSpec.objects.length} | Agents:{' '} + {envSpec.agents.length}
+ + {/* Object Tooltip - Shows info and delete button when clicking on objects */} + {tooltip.visible && (tooltip.object || tooltip.agent) && ( +
+
+ {/* Object/Agent Info */} +
+
+ {tooltip.object ? ( + {tooltip.object.type} + ) : ( + <>Agent + )} +
+ {tooltip.object && tooltip.object.position && ( +
+ Position: ({Math.round(tooltip.object.position[0])},{' '} + {Math.round(tooltip.object.position[1])}) +
+ )} + {tooltip.agent && tooltip.agent.position && ( +
+ Position: ({Math.round(tooltip.agent.position[0])},{' '} + {Math.round(tooltip.agent.position[1])}) +
+ )} +
+ + {/* Delete Button */} + +
+
+ )}
) } -