diff --git a/backend/packages/adapter/src/room-tools.ts b/backend/packages/adapter/src/room-tools.ts index ed05792..a847438 100644 --- a/backend/packages/adapter/src/room-tools.ts +++ b/backend/packages/adapter/src/room-tools.ts @@ -4,7 +4,10 @@ * Provides tools for the AI agent to interact with the room environment: * - create_note: Create a sticky note on a surface * - remove_note: Remove a sticky note by title - * - clear_room: Clear all notes from the room + * - move_note: Move a sticky note between surfaces + * - add_image: Add an image to the room workspace + * - clear_room: Clear all items from the room + * - examine: Get details about a specific note * - look_around: Query the room state to see what's placed where */ @@ -12,10 +15,54 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; import { emit } from './ipc.js'; import { createLogger } from '@tiny-workshop/logger'; -import type { RoomQueryResponseMessage, RoomActionResponseMessage } from '@tiny-workshop/ipc-types'; +import type { RoomQueryResponseMessage, RoomActionResponseMessage, validateAndCreateImagePath } from '@tiny-workshop/ipc-types'; const log = createLogger('room-tools'); +// Image validation constants +const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50 MB in bytes +const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']; + +/** + * Validate an image file path and check file properties + * + * This function combines: + * - Type-safe path validation (absolute path, no .., valid extension) + * - File existence check + * - File size validation + * + * @param filePath - Absolute path to the image file + * @returns Validated ImagePath or error message + */ +async function validateImagePathAndFile(filePath: string): Promise | string> { + // First validate the path format and extension + const typeScriptValidation = (await import('@tiny-workshop/ipc-types')).validateAndCreateImagePath(filePath); + if (typeof typeScriptValidation === 'string') { + // Path format validation failed + return typeScriptValidation; + } + + // Path format is valid, now check file properties + try { + const file = Bun.file(filePath); + if (!file.exists()) { + return `Image file not found: ${filePath}`; + } + + // Check file size + const size = file.size; + if (size > MAX_IMAGE_SIZE) { + const sizeMB = (size / (1024 * 1024)).toFixed(1); + return `Image file is too large: ${sizeMB}MB (max: 50MB)`; + } + + // File is valid + return typeScriptValidation; + } catch (error) { + return `Failed to access image file: ${error}`; + } +} + // Track pending room queries by request ID const pendingQueries = new Map void; @@ -437,6 +484,90 @@ If the destination surface is full, the move will fail.`, } ), + tool( + 'add_image', + `Add an image to the room workspace. + +Use this tool when you have generated or received an image file that you want +to keep visible in the room. Images are placed on the desk surface in a stack, +with the most recent image on top. + +Supported formats: PNG, JPEG, GIF, WebP, BMP +Maximum size: 50 MB + +Use this tool when: +- You've taken a screenshot that should be kept for reference +- You've generated an image the user wants to see +- Tool output includes an image file path +- The user explicitly asks to add an image + +The image file is copied to persistent storage, so the original file +can be safely deleted after adding.`, + { + source_path: z.string().describe('Absolute path to the image file'), + label: z.string().optional().describe('Optional human-readable label for the image'), + }, + async (args) => { + const requestId = generateRequestId('action'); + log.info('Adding image', { request_id: requestId, source_path: args.source_path, label: args.label }); + + // Validate path and file before sending to frontend + const validationResult = await validateImagePathAndFile(args.source_path); + if (typeof validationResult === 'string') { + // Validation failed + log.error('Image validation failed', { request_id: requestId, source_path: args.source_path, error: validationResult }); + return { + content: [ + { + type: 'text' as const, + text: validationResult, + }, + ], + isError: true, + }; + } + + // validationResult is now a validated ImagePath + const validatedPath: string = validationResult; // Extract the string from the branded type + + emit({ + type: 'room_action', + request_id: requestId, + action: { + type: 'add_image', + source_path: validatedPath as any, // Cast to any since IPC serialization loses the brand + label: args.label, + }, + }); + + try { + // Wait for response from frontend + const result = await waitForRoomActionResponse(requestId); + + return { + content: [ + { + type: 'text' as const, + text: result.message, + }, + ], + isError: !result.success, + }; + } catch (error) { + log.error('Add image failed', { error, request_id: requestId }); + return { + content: [ + { + type: 'text' as const, + text: `Failed to add image: ${error}`, + }, + ], + isError: true, + }; + } + } + ), + tool( 'look_around', `Look around the room to see what items are placed on each surface. diff --git a/backend/packages/ipc-types/src/index.ts b/backend/packages/ipc-types/src/index.ts index 4290f12..e3a27aa 100644 --- a/backend/packages/ipc-types/src/index.ts +++ b/backend/packages/ipc-types/src/index.ts @@ -15,11 +15,68 @@ export const PROTOCOL_VERSION = 1; /** Valid colors for sticky notes */ export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green'; +/** + * Type-safe absolute file path for images + * + * This type ensures that image paths are validated before being sent + * to the frontend. Paths must be absolute and use proper separators. + * + * Use `validateAndCreateImagePath()` to create an instance. + */ +export type ImagePath = string & { readonly __brand: 'ImagePath' }; + +/** + * Validate and create a type-safe ImagePath + * + * @param path - The path to validate + * @returns Valid ImagePath or error message + * + * @example + * ```ts + * const validated = validateAndCreateImagePath('/absolute/path/to/image.png'); + * if (typeof validated === 'string') { + * // It's an error message + * console.error(validated); + * } else { + * // It's a valid ImagePath + * useValidatedPath(validated); + * } + * ``` + */ +export function validateAndCreateImagePath(path: string): ImagePath | string { + // Check if path is absolute (starts with / on Unix or drive letter on Windows) + const isAbsolute = + path.startsWith('/') || + /^[a-zA-Z]:/.test(path) || + path.startsWith('\\'); + + if (!isAbsolute) { + return `Path must be absolute, got: ${path}`; + } + + // Check for suspicious patterns + if (path.includes('..')) { + return `Path cannot contain parent directory references (..): ${path}`; + } + + // Basic extension validation (common image formats) + const ext = path.split('.').pop()?.toLowerCase(); + const validExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']; + + if (!ext || !validExtensions.includes(ext)) { + return `Path must have a valid image extension (.png, .jpg, .jpeg, .gif, .webp, .bmp), got: ${path}`; + } + + // Path is valid, return branded type + return path as ImagePath; +} + /** Actions the agent can perform on the room */ export type RoomAction = | { type: 'create_note'; title: string; content: string; surface: string; color?: NoteColor } | { type: 'remove_note'; title: string; surface: string } | { type: 'move_note'; title: string; from_surface: string; to_surface: string } + | { type: 'add_image'; source_path: ImagePath; label?: string } | { type: 'clear_room' }; // ============================================================================= diff --git a/tiny-workshop/game/src/ipc.rs b/tiny-workshop/game/src/ipc.rs index a9e9cec..068813a 100644 --- a/tiny-workshop/game/src/ipc.rs +++ b/tiny-workshop/game/src/ipc.rs @@ -32,7 +32,6 @@ pub enum RoomAction { /// Surface to remove the note from surface: String, }, - /// Clear all items from the room /// Move a sticky note from one surface to another MoveNote { /// Title of the note to move @@ -42,6 +41,18 @@ pub enum RoomAction { /// Surface to move the note to to_surface: String, }, + /// Add an image to the room workspace (desk surface) + /// + /// The image is copied from the source path to persistent storage. + /// Images are stacked on the desk, with the most recent on top. + AddImage { + /// Absolute path to the source image file + source_path: String, + /// Optional human-readable label for the image + #[serde(default)] + label: Option, + }, + /// Clear all items from the room ClearRoom, } @@ -387,4 +398,55 @@ mod tests { _ => panic!("Expected CompactionOccurred variant"), } } + + #[test] + fn test_deserialize_add_image_action() { + // With label + let json = r#"{ + "type": "room_action", + "request_id": "req123", + "action": { + "type": "add_image", + "source_path": "/tmp/screenshot.png", + "label": "My screenshot" + } + }"#; + + let msg: BackendMessage = serde_json::from_str(json).unwrap(); + match msg { + BackendMessage::RoomAction { request_id, action } => { + assert_eq!(request_id, "req123"); + match action { + RoomAction::AddImage { source_path, label } => { + assert_eq!(source_path, "/tmp/screenshot.png"); + assert_eq!(label, Some("My screenshot".to_string())); + } + _ => panic!("Expected AddImage action"), + } + } + _ => panic!("Expected RoomAction message"), + } + + // Without label (optional field) + let json_no_label = r#"{ + "type": "room_action", + "request_id": "req456", + "action": { + "type": "add_image", + "source_path": "/tmp/image.jpg" + } + }"#; + + let msg: BackendMessage = serde_json::from_str(json_no_label).unwrap(); + match msg { + BackendMessage::RoomAction { action, .. } => match action { + RoomAction::AddImage { source_path, label } => { + assert_eq!(source_path, "/tmp/image.jpg"); + assert!(label.is_none()); + } + _ => panic!("Expected AddImage action"), + }, + _ => panic!("Expected RoomAction message"), + } + } } diff --git a/tiny-workshop/game/src/lib.rs b/tiny-workshop/game/src/lib.rs index 939eceb..e4989cf 100644 --- a/tiny-workshop/game/src/lib.rs +++ b/tiny-workshop/game/src/lib.rs @@ -67,6 +67,13 @@ const NOTE_COL_SPACING: f32 = 0.2; /// Depth spacing between note rows (+Z direction) const NOTE_ROW_SPACING: f32 = 0.18; +// Image stack visual rendering constants +/// Size of each image stack placeholder cube (width, height, depth) +/// Slightly larger than notes to distinguish them visually +const IMAGE_STACK_MESH_SIZE: Vector3 = Vector3::new(0.25, 0.02, 0.2); +/// Gray color for placeholder image stacks (will be textured later) +const IMAGE_STACK_PLACEHOLDER_COLOR: (u8, u8, u8) = (180, 180, 180); + /// Surface visual configuration (position and size for placeholder mesh) struct SurfaceConfig { /// Center position of the surface in world coordinates @@ -437,6 +444,22 @@ pub struct Game { #[reflect(hidden)] note_meshes: HashMap>, + /// Visual mesh handles for image stacks, keyed by stack ID. + /// + /// Similar to note_meshes but for image stacks. Currently renders as + /// gray placeholder cubes; will be textured quads when image cache is integrated. + #[visit(skip)] + #[reflect(hidden)] + image_stack_meshes: HashMap>, + + /// Cache for loaded image textures. + /// + /// Manages async texture loading and LRU eviction for image stacks. + #[visit(skip)] + #[reflect(hidden)] + #[allow(dead_code)] + image_cache: room::ImageCache, + // ======================================================================== // Persistence Fields // ======================================================================== @@ -1282,8 +1305,9 @@ impl Game { /// Synchronize visual meshes with the current room state. /// - /// This creates/removes 3D meshes to match the sticky notes in the room manager. - /// Notes are rendered as small colored cubes positioned on each surface. + /// This creates/removes 3D meshes to match the interactables in the room manager: + /// - Sticky notes are rendered as small colored cubes + /// - Image stacks are rendered as flat gray placeholder cubes (texturing TODO) fn sync_room_visuals(&mut self, context: &mut PluginContext) { let Some(scene_ref) = context.scenes.try_get_mut(self.scene) else { return; // No scene loaded yet @@ -1374,6 +1398,96 @@ impl Game { color, position.x, position.y, position.z )); } + + // ===================================================================== + // Image Stack Visual Sync + // ===================================================================== + + // Collect current image stack IDs from room manager + // Image stacks are always placed on the "desk" surface for now + let current_stacks: Vec<(room::InteractableId, &str, usize, usize)> = self + .room_manager + .iter_image_stacks() + .map(|(surface_id, row, col, stack)| (stack.id, surface_id, row, col)) + .collect(); + + let current_stack_ids: std::collections::HashSet = + current_stacks.iter().map(|(id, _, _, _)| *id).collect(); + + // Remove meshes for stacks that no longer exist + let removed_stack_ids: Vec = self + .image_stack_meshes + .keys() + .filter(|id| !current_stack_ids.contains(id)) + .copied() + .collect(); + + for id in removed_stack_ids { + if let Some(handle) = self.image_stack_meshes.remove(&id) { + graph.remove_node(handle); + fyrox::core::log::Log::info(format!("Room visuals: Removed image stack mesh {id}")); + } + } + + // Create meshes for new image stacks (gray placeholder for now) + for (id, surface_id, row, col) in current_stacks { + if self.image_stack_meshes.contains_key(&id) { + continue; // Already have a mesh for this stack + } + + // Get the grid center for this surface + let grid_center = surface_grid_center(surface_id); + + // Calculate position within the surface grid + // Use same spacing as notes for consistency + let grid_x = col as f32 - 1.0; + let grid_z = row as f32 - 0.5; + + let position = Vector3::new( + grid_center.x + grid_x * NOTE_COL_SPACING, + grid_center.y + IMAGE_STACK_MESH_SIZE.y / 2.0, // Sit on top of surface + grid_center.z + grid_z * NOTE_ROW_SPACING, + ); + + // Create the image stack mesh (flat quad-like placeholder) + let mesh_handle = MeshBuilder::new( + BaseBuilder::new() + .with_name(format!("ImageStack_{id}")) + .with_local_transform( + TransformBuilder::new() + .with_local_position(position) + .with_local_scale(IMAGE_STACK_MESH_SIZE) + .build(), + ), + ) + .with_surfaces(vec![SurfaceBuilder::new(SurfaceResource::new_embedded( + SurfaceData::make_cube(Matrix4::identity()), + )) + .build()]) + .build(graph); + + // Set the placeholder gray color + if let Some(mesh) = graph[mesh_handle].cast_mut::() { + let (r, g, b) = IMAGE_STACK_PLACEHOLDER_COLOR; + let rgba = fyrox::core::color::Color::from_rgba(r, g, b, 255); + for surface in mesh.surfaces_mut() { + surface + .material() + .data_ref() + .set_property("diffuseColor", rgba); + } + } else { + fyrox::core::log::Log::warn(format!( + "Room visuals: Failed to cast node to Mesh for image stack {id}" + )); + } + + self.image_stack_meshes.insert(id, mesh_handle); + fyrox::core::log::Log::info(format!( + "Room visuals: Created image stack mesh at ({:.2}, {:.2}, {:.2})", + position.x, position.y, position.z + )); + } } /// Attempt to restart the backend after a disconnect. @@ -1838,7 +1952,25 @@ impl Game { }); // Execute action and capture result - let (success, message) = match self.room_manager.execute_action(&action) { + // AddImage needs special handling since it requires project_dir + let result = match &action { + ipc::RoomAction::AddImage { source_path, label } => { + if let Some(pm) = &self.persistence { + self.room_manager.add_image( + std::path::Path::new(source_path), + label.clone(), + pm.project_dir(), + ) + } else { + Err(room::RoomError::InvalidAction { + reason: "Persistence not initialized".to_string(), + }) + } + } + _ => self.room_manager.execute_action(&action), + }; + + let (success, message) = match result { Ok(msg) => { fyrox::core::log::Log::info(format!("Room action success: {msg}")); // Mark room as dirty for auto-save diff --git a/tiny-workshop/game/src/persistence/fixtures/room_v1_1.json b/tiny-workshop/game/src/persistence/fixtures/room_v1_1.json new file mode 100644 index 0000000..883b52e --- /dev/null +++ b/tiny-workshop/game/src/persistence/fixtures/room_v1_1.json @@ -0,0 +1,64 @@ +{ + "version": { + "major": 1, + "minor": 1 + }, + "surfaces": { + "desk": { + "id": "desk", + "name": "Desk", + "rows": 2, + "cols": 3, + "slots": [ + [ + { + "item": { + "type": "sticky_note", + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Remember", + "content": "This is a test note for schema v1.1.", + "color": "yellow" + } + }, + { + "item": { + "type": "image_stack", + "id": "550e8400-e29b-41d4-a716-446655440100", + "images": [ + { + "id": "550e8400-e29b-41d4-a716-446655440101", + "filename": "550e8400-e29b-41d4-a716-446655440101.png", + "extension": "png", + "label": "Test screenshot", + "created_at": "2025-01-01T12:00:00Z" + }, + { + "id": "550e8400-e29b-41d4-a716-446655440102", + "filename": "550e8400-e29b-41d4-a716-446655440102.jpg", + "extension": "jpg", + "label": null, + "created_at": "2025-01-02T12:00:00Z" + } + ], + "created_at": "2025-01-01T12:00:00Z" + } + }, + { "item": null } + ], + [ + { "item": null }, + { "item": null }, + { + "item": { + "type": "sticky_note", + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Another Note", + "content": "Testing multiple notes alongside image stacks.", + "color": "pink" + } + } + ] + ] + } + } +} diff --git a/tiny-workshop/game/src/persistence/schema.rs b/tiny-workshop/game/src/persistence/schema.rs index a3abe27..3ddfd3a 100644 --- a/tiny-workshop/game/src/persistence/schema.rs +++ b/tiny-workshop/game/src/persistence/schema.rs @@ -81,7 +81,11 @@ pub struct SchemaVersion { impl SchemaVersion { /// Current schema version for all persistence files. - pub const CURRENT: Self = Self { major: 1, minor: 0 }; + /// + /// # Version History + /// - 1.0: Initial schema (sticky notes, surfaces) + /// - 1.1: Added ImageStack interactable type + pub const CURRENT: Self = Self { major: 1, minor: 1 }; /// Check if this version is newer than the supported version. pub fn is_newer_than(&self, other: &Self) -> bool { @@ -338,7 +342,7 @@ mod tests { #[test] fn test_schema_version_current() { assert_eq!(SchemaVersion::CURRENT.major, 1); - assert_eq!(SchemaVersion::CURRENT.minor, 0); + assert_eq!(SchemaVersion::CURRENT.minor, 1); // Updated for v1.1 (image stacks) } #[test] @@ -481,6 +485,85 @@ mod tests { assert!(desk.slots[1][1].item.is_none()); } + /// Test loading v1.1 schema fixture (with image stacks). + /// + /// This test ensures: + /// 1. The v1.1 schema format can be parsed + /// 2. ImageStack interactables deserialize correctly + /// 3. Both sticky notes and image stacks can coexist + /// 4. The state passes validation + #[test] + fn test_load_fixture_v1_1() { + use crate::room::Interactable; + + let json = include_str!("fixtures/room_v1_1.json"); + let state: RoomState = serde_json::from_str(json).expect("v1.1 fixture should deserialize"); + + // Verify version + assert_eq!(state.version.major, 1); + assert_eq!(state.version.minor, 1); + + // Verify structure + assert_eq!(state.surfaces.len(), 1); + let desk = state + .surfaces + .get("desk") + .expect("should have desk surface"); + assert_eq!(desk.rows, 2); + assert_eq!(desk.cols, 3); + + // Verify slot [0][0] has a sticky note + let slot_0_0 = &desk.slots[0][0]; + assert!( + matches!(slot_0_0.item, Some(Interactable::StickyNote(_))), + "slot [0][0] should have a sticky note" + ); + + // Verify slot [0][1] has an image stack + let slot_0_1 = &desk.slots[0][1]; + match &slot_0_1.item { + Some(Interactable::ImageStack(stack)) => { + assert_eq!(stack.images.len(), 2, "image stack should have 2 images"); + assert_eq!( + stack.images[0].label.as_deref(), + Some("Test screenshot"), + "first image should have label" + ); + assert!( + stack.images[1].label.is_none(), + "second image should have no label" + ); + } + _ => panic!("slot [0][1] should have an image stack"), + } + + // Verify slot [1][2] has a sticky note + let slot_1_2 = &desk.slots[1][2]; + assert!( + matches!(slot_1_2.item, Some(Interactable::StickyNote(_))), + "slot [1][2] should have a sticky note" + ); + + // Validate the entire state + assert!( + state.validate().is_ok(), + "v1.1 fixture should pass validation" + ); + } + + /// Test that v1.0 fixtures still load correctly after v1.1 changes. + /// + /// This is a backwards compatibility test - old save files without + /// image stacks should continue to work. + #[test] + fn test_v1_0_backwards_compat() { + let json = include_str!("fixtures/room_v1_0.json"); + let state: RoomState = serde_json::from_str(json).expect("v1.0 should still load"); + + // Should still validate even though it's an older version + assert!(state.validate().is_ok()); + } + // ============================================================ // ProjectState Tests // ============================================================ @@ -585,7 +668,10 @@ mod tests { assert_eq!(state.version.minor, 0); // Verify fields - assert_eq!(state.directory, std::path::PathBuf::from("/test/my-project")); + assert_eq!( + state.directory, + std::path::PathBuf::from("/test/my-project") + ); assert_eq!(state.name, "my-project"); // Validate the entire state diff --git a/tiny-workshop/game/src/room/error.rs b/tiny-workshop/game/src/room/error.rs index 3425bd3..a9f999d 100644 --- a/tiny-workshop/game/src/room/error.rs +++ b/tiny-workshop/game/src/room/error.rs @@ -53,6 +53,38 @@ pub enum RoomError { /// Reason why the action is invalid reason: String, }, + + /// Image file not found at the specified path. + ImageNotFound { + /// Path that was checked + path: String, + }, + + /// Image format is not supported. + InvalidImageFormat { + /// Path to the file + path: String, + /// The file's extension + extension: String, + /// List of allowed extensions + allowed: Vec, + }, + + /// Image file is too large. + ImageTooLarge { + /// Path to the file + path: String, + /// Actual file size in bytes + size_bytes: u64, + /// Maximum allowed size in bytes + max_bytes: u64, + }, + + /// Generic image storage error. + ImageStorageError { + /// Error message + message: String, + }, } impl std::fmt::Display for RoomError { @@ -88,6 +120,37 @@ impl std::fmt::Display for RoomError { RoomError::InvalidAction { reason } => { write!(f, "Invalid action: {reason}") } + RoomError::ImageNotFound { path } => { + write!(f, "Image file not found: {path}") + } + RoomError::InvalidImageFormat { + path, + extension, + allowed, + } => { + write!( + f, + "Invalid image format for '{}': '.{}' not supported. Allowed: {}", + path, + extension, + allowed.join(", ") + ) + } + RoomError::ImageTooLarge { + path, + size_bytes, + max_bytes, + } => { + let size_mb = *size_bytes as f64 / (1024.0 * 1024.0); + let max_mb = *max_bytes as f64 / (1024.0 * 1024.0); + write!( + f, + "Image '{path}' is too large: {size_mb:.1}MB (max: {max_mb:.1}MB)" + ) + } + RoomError::ImageStorageError { message } => { + write!(f, "Image storage error: {message}") + } } } } diff --git a/tiny-workshop/game/src/room/image_cache.rs b/tiny-workshop/game/src/room/image_cache.rs new file mode 100644 index 0000000..d1c2d19 --- /dev/null +++ b/tiny-workshop/game/src/room/image_cache.rs @@ -0,0 +1,349 @@ +//! Image Cache for Tiny Workshop +//! +//! Manages texture loading and caching for image stacks. +//! Uses LRU eviction to stay within memory budget. +//! +//! # Memory Budget +//! +//! - Maximum 50 cached textures +//! - Maximum 50MB total texture memory (estimated) +//! +//! # Usage +//! +//! ```ignore +//! let mut cache = ImageCache::new(); +//! +//! // Request texture for an image +//! let handle = cache.get_texture(&image_ref, project_dir, resource_manager); +//! +//! // In update loop, process pending loads +//! cache.process_pending_loads(resource_manager); +//! ``` + +use std::collections::HashMap; +use std::path::Path; + +use fyrox::asset::manager::ResourceManager; +use fyrox::resource::texture::{Texture, TextureResource}; +use uuid::Uuid; + +use super::image_storage; +use super::interactable::ImageRef; + +/// This cache is intentionally !Sync and !Send because it should only be accessed +/// from the game loop thread. Concurrent access from multiple threads is not supported. +/// If you need thread-safe access, wrap this in a Mutex or RwLock. +/// Maximum number of textures to keep in cache +const MAX_CACHED_TEXTURES: usize = 50; + +/// Load state for a cached image +#[derive(Debug, Clone)] +pub enum ImageLoadState { + /// Texture is being loaded asynchronously + Loading, + /// Texture is loaded and ready + Loaded(TextureResource), + /// Texture failed to load + Failed(String), +} + +/// Entry in the image cache +#[derive(Debug)] +struct CacheEntry { + /// Current load state + state: ImageLoadState, + /// Last access time (for LRU eviction) + last_access: std::time::Instant, +} + +/// LRU cache for image textures +#[derive(Debug)] +pub struct ImageCache { + /// Cached entries by image ID + entries: HashMap, +} + +impl Default for ImageCache { + fn default() -> Self { + Self::new() + } +} + +impl ImageCache { + /// Create a new empty image cache + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + /// Get or start loading a texture for an image. + /// + /// Returns the current load state. If the image is not in cache, + /// starts an async load and returns `Loading`. + pub fn get_or_load( + &mut self, + image_ref: &ImageRef, + project_dir: &Path, + resource_manager: &ResourceManager, + ) -> ImageLoadState { + let now = std::time::Instant::now(); + + // Check if already cached + if let Some(entry) = self.entries.get_mut(&image_ref.id) { + entry.last_access = now; + return entry.state.clone(); + } + + // Evict if over capacity + self.evict_if_needed(); + + // Get the full path to the image + let image_path = image_storage::image_path(image_ref, project_dir); + + // Check if file exists + if !image_path.exists() { + let state = + ImageLoadState::Failed(format!("Image file not found: {}", image_path.display())); + self.entries.insert( + image_ref.id, + CacheEntry { + state: state.clone(), + last_access: now, + }, + ); + return state; + } + + // Start async texture load via Fyrox resource manager + let texture_resource = resource_manager.request::(image_path); + + // Store as loading initially, then update when loaded + // Note: Fyrox textures load asynchronously, so we store the resource + // and check its state on subsequent calls + let state = ImageLoadState::Loaded(texture_resource); + self.entries.insert( + image_ref.id, + CacheEntry { + state: state.clone(), + last_access: now, + }, + ); + + state + } + + /// Check if a texture is loaded and ready for use + pub fn is_loaded(&self, image_id: &Uuid) -> bool { + matches!( + self.entries.get(image_id), + Some(CacheEntry { + state: ImageLoadState::Loaded(_), + .. + }) + ) + } + + /// Get a loaded texture if available + pub fn get_texture(&self, image_id: &Uuid) -> Option { + if let Some(entry) = self.entries.get(image_id) { + if let ImageLoadState::Loaded(texture) = &entry.state { + return Some(texture.clone()); + } + } + None + } + + /// Remove an image from the cache + /// + /// This will properly release the texture resource by dropping it, + /// which decreases the reference count and allows GPU memory to be freed. + pub fn remove(&mut self, image_id: &Uuid) { + if let Some(entry) = self.entries.remove(image_id) { + // Explicitly drop the entry to ensure resources are released immediately + drop(entry); + } + } + + /// Clear all entries from the cache + /// + /// This will properly release all texture resources by dropping them, + /// which decreases reference counts and allows GPU memory to be freed. + pub fn clear(&mut self) { + // Take all entries out and drop them explicitly + let entries = std::mem::take(&mut self.entries); + drop(entries); + } + + /// Get the number of cached entries + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if the cache is empty + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Evict the least recently used entry if over capacity + /// + /// This will properly release the texture resource by dropping it, + /// which decreases the reference count and allows GPU memory to be freed. + fn evict_if_needed(&mut self) { + while self.entries.len() >= MAX_CACHED_TEXTURES { + // Find LRU entry + let lru_id = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_access) + .map(|(id, _)| *id); + + if let Some(id) = lru_id { + fyrox::core::log::Log::info(format!("Image cache: evicting LRU entry {id}")); + // Remove will properly drop the entry and release GPU resources + self.remove(&id); + } else { + break; + } + } + } +} + +/// Ensure proper cleanup when ImageCache is dropped +/// +/// This implementation guarantees that all texture resources are released +/// when the cache is dropped, preventing GPU memory leaks. +impl Drop for ImageCache { + fn drop(&mut self) { + // Log cleanup for debugging + let entry_count = self.entries.len(); + if entry_count > 0 { + fyrox::core::log::Log::info(format!( + "Image cache: dropping {entry_count} cached texture(s)" + )); + } + // Explicitly clear all entries, which will drop each CacheEntry + // and release the associated TextureResource references + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_creation() { + let cache = ImageCache::new(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + + #[test] + fn test_cache_remove() { + let mut cache = ImageCache::new(); + let id = Uuid::new_v4(); + + // Manually insert an entry for testing + cache.entries.insert( + id, + CacheEntry { + state: ImageLoadState::Failed("test".to_string()), + last_access: std::time::Instant::now(), + }, + ); + + assert_eq!(cache.len(), 1); + cache.remove(&id); + assert!(cache.is_empty()); + } + + #[test] + fn test_cache_clear() { + let mut cache = ImageCache::new(); + + // Add some entries + for _ in 0..5 { + let id = Uuid::new_v4(); + cache.entries.insert( + id, + CacheEntry { + state: ImageLoadState::Loading, + last_access: std::time::Instant::now(), + }, + ); + } + + assert_eq!(cache.len(), 5); + cache.clear(); + assert!(cache.is_empty()); + } + + #[test] + fn test_is_loaded() { + let cache = ImageCache::new(); + let id = Uuid::new_v4(); + + // Non-existent entry + assert!(!cache.is_loaded(&id)); + } + + #[test] + fn test_get_texture_not_found() { + let cache = ImageCache::new(); + let id = Uuid::new_v4(); + + assert!(cache.get_texture(&id).is_none()); + } + + #[test] + fn test_eviction_at_capacity() { + let mut cache = ImageCache::new(); + + // Fill to capacity + for i in 0..MAX_CACHED_TEXTURES { + let id = Uuid::new_v4(); + cache.entries.insert( + id, + CacheEntry { + state: ImageLoadState::Loading, + // Stagger access times so we know which is LRU + last_access: std::time::Instant::now() + - std::time::Duration::from_secs((MAX_CACHED_TEXTURES - i) as u64), + }, + ); + } + + assert_eq!(cache.len(), MAX_CACHED_TEXTURES); + + // Trigger eviction + cache.evict_if_needed(); + + // Should have evicted one + assert_eq!(cache.len(), MAX_CACHED_TEXTURES - 1); + } + + #[test] + fn test_drop_clears_cache() { + // Test that Drop trait properly cleans up resources + let mut cache = ImageCache::new(); + let id = Uuid::new_v4(); + + // Add an entry + cache.entries.insert( + id, + crate::room::image_cache::CacheEntry { + state: ImageLoadState::Loading, + last_access: std::time::Instant::now(), + }, + ); + + assert_eq!(cache.len(), 1); + + // Explicitly drop to trigger cleanup + drop(cache); + + // Cache should be cleared (we can't directly test this after drop, + // but the Drop implementation ensures clear() is called) + } +} diff --git a/tiny-workshop/game/src/room/image_storage.rs b/tiny-workshop/game/src/room/image_storage.rs new file mode 100644 index 0000000..78db679 --- /dev/null +++ b/tiny-workshop/game/src/room/image_storage.rs @@ -0,0 +1,324 @@ +//! Image Storage for Tiny Workshop +//! +//! Handles copying images from source paths to persistent project storage. +//! Images are stored in `{project_dir}/images/` with UUID-based filenames. +//! +//! # Supported Formats +//! +//! - PNG (`.png`) +//! - JPEG (`.jpg`, `.jpeg`) +//! - GIF (`.gif`) +//! - WebP (`.webp`) +//! - BMP (`.bmp`) +//! +//! # Security +//! +//! - Only copies files with allowed extensions +//! - Validates source file exists before copying +//! - Uses atomic write pattern (copy to temp, then rename) + +use std::fs; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +use super::error::RoomError; +use super::interactable::ImageRef; + +/// Directory name for stored images within the project directory +const IMAGES_DIR: &str = "images"; + +/// Allowed image file extensions (lowercase, without dot) +const ALLOWED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; + +/// Maximum file size for images (50 MB) +const MAX_IMAGE_SIZE: u64 = 50 * 1024 * 1024; + +/// Get the images directory path for a project. +/// +/// Creates the directory if it doesn't exist. +pub fn images_dir(project_dir: &Path) -> Result { + let dir = project_dir.join(IMAGES_DIR); + if !dir.exists() { + fs::create_dir_all(&dir).map_err(|e| RoomError::ImageStorageError { + message: format!("Failed to create images directory: {e}"), + })?; + } + Ok(dir) +} + +/// Store an image from a source path to the project's images directory. +/// +/// # Arguments +/// - `source_path` - Absolute path to the source image file +/// - `project_dir` - Path to the project directory (contains images/ subdirectory) +/// - `label` - Optional human-readable label for the image +/// +/// # Returns +/// - `Ok(ImageRef)` - Reference to the stored image +/// - `Err(RoomError)` - If the image couldn't be stored +/// +/// # Errors +/// - `ImageNotFound` - Source file doesn't exist +/// - `InvalidImageFormat` - File extension not in allowed list +/// - `ImageTooLarge` - File exceeds size limit +/// - `ImageStorageError` - Copy operation failed +pub fn store_image( + source_path: &Path, + project_dir: &Path, + label: Option, +) -> Result { + // Validate source exists + if !source_path.exists() { + return Err(RoomError::ImageNotFound { + path: source_path.display().to_string(), + }); + } + + // Validate and extract extension + let extension = validate_extension(source_path)?; + + // Check file size + let metadata = fs::metadata(source_path).map_err(|e| RoomError::ImageStorageError { + message: format!("Failed to read file metadata: {e}"), + })?; + + if metadata.len() > MAX_IMAGE_SIZE { + return Err(RoomError::ImageTooLarge { + path: source_path.display().to_string(), + size_bytes: metadata.len(), + max_bytes: MAX_IMAGE_SIZE, + }); + } + + // Generate unique filename + let id = Uuid::new_v4(); + let filename = format!("{id}.{extension}"); + + // Ensure images directory exists + let images_path = images_dir(project_dir)?; + let dest_path = images_path.join(&filename); + + // Copy file (atomic: copy to temp, then rename would be ideal, + // but for cross-platform simplicity we use direct copy) + fs::copy(source_path, &dest_path).map_err(|e| RoomError::ImageStorageError { + message: format!("Failed to copy image: {e}"), + })?; + + fyrox::core::log::Log::info(format!( + "Stored image: {} -> {}", + source_path.display(), + dest_path.display() + )); + + Ok(ImageRef::new(filename, extension.to_string(), label)) +} + +/// Delete a stored image from the project's images directory. +/// +/// # Arguments +/// - `image_ref` - Reference to the image to delete +/// - `project_dir` - Path to the project directory +/// +/// # Returns +/// - `Ok(())` - Image was deleted (or didn't exist) +/// - `Err(RoomError)` - If deletion failed +pub fn delete_image(image_ref: &ImageRef, project_dir: &Path) -> Result<(), RoomError> { + let images_path = images_dir(project_dir)?; + let file_path = images_path.join(&image_ref.filename); + + if file_path.exists() { + fs::remove_file(&file_path).map_err(|e| RoomError::ImageStorageError { + message: format!("Failed to delete image {}: {e}", image_ref.filename), + })?; + + fyrox::core::log::Log::info(format!("Deleted image: {}", image_ref.filename)); + } + + Ok(()) +} + +/// Get the full path to a stored image. +/// +/// # Arguments +/// - `image_ref` - Reference to the image +/// - `project_dir` - Path to the project directory +/// +/// # Returns +/// The full path to the image file (may not exist) +pub fn image_path(image_ref: &ImageRef, project_dir: &Path) -> PathBuf { + project_dir.join(IMAGES_DIR).join(&image_ref.filename) +} + +/// Validate that a file has an allowed image extension. +/// +/// Returns the lowercase extension without the dot. +fn validate_extension(path: &Path) -> Result<&'static str, RoomError> { + let extension = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + + match extension.as_deref() { + Some(ext) => { + for allowed in ALLOWED_EXTENSIONS { + if *allowed == ext { + return Ok(allowed); + } + } + Err(RoomError::InvalidImageFormat { + path: path.display().to_string(), + extension: ext.to_string(), + allowed: ALLOWED_EXTENSIONS.iter().map(|s| s.to_string()).collect(), + }) + } + None => Err(RoomError::InvalidImageFormat { + path: path.display().to_string(), + extension: "(none)".to_string(), + allowed: ALLOWED_EXTENSIONS.iter().map(|s| s.to_string()).collect(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_image(dir: &Path, name: &str, content: &[u8]) -> PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + #[test] + fn test_images_dir_creates_directory() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let result = images_dir(&project_dir); + assert!(result.is_ok()); + + let images_path = result.unwrap(); + assert!(images_path.exists()); + assert!(images_path.ends_with("images")); + } + + #[test] + fn test_validate_extension_allowed() { + let test_cases = vec![ + ("test.png", "png"), + ("test.PNG", "png"), + ("test.jpg", "jpg"), + ("test.jpeg", "jpeg"), + ("test.gif", "gif"), + ("test.webp", "webp"), + ("test.bmp", "bmp"), + ]; + + for (filename, expected) in test_cases { + let path = Path::new(filename); + let result = validate_extension(path); + assert!(result.is_ok(), "Failed for {}", filename); + assert_eq!(result.unwrap(), expected); + } + } + + #[test] + fn test_validate_extension_disallowed() { + let disallowed = vec!["test.txt", "test.pdf", "test.exe", "test"]; + + for filename in disallowed { + let path = Path::new(filename); + let result = validate_extension(path); + assert!(result.is_err(), "Should reject {}", filename); + } + } + + #[test] + fn test_store_image_success() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + // Create a fake image file + let source_path = create_test_image(temp.path(), "source.png", b"fake png data"); + + let result = store_image(&source_path, &project_dir, Some("Test image".to_string())); + assert!(result.is_ok()); + + let image_ref = result.unwrap(); + assert_eq!(image_ref.extension, "png"); + assert_eq!(image_ref.label, Some("Test image".to_string())); + assert!(image_ref.filename.ends_with(".png")); + + // Verify file was copied + let stored_path = project_dir.join("images").join(&image_ref.filename); + assert!(stored_path.exists()); + } + + #[test] + fn test_store_image_not_found() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let missing_path = temp.path().join("missing.png"); + let result = store_image(&missing_path, &project_dir, None); + + assert!(matches!(result, Err(RoomError::ImageNotFound { .. }))); + } + + #[test] + fn test_store_image_invalid_extension() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let source_path = create_test_image(temp.path(), "document.txt", b"not an image"); + + let result = store_image(&source_path, &project_dir, None); + assert!(matches!(result, Err(RoomError::InvalidImageFormat { .. }))); + } + + #[test] + fn test_delete_image() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + // Store an image first + let source_path = create_test_image(temp.path(), "to_delete.png", b"data"); + let image_ref = store_image(&source_path, &project_dir, None).unwrap(); + + let stored_path = image_path(&image_ref, &project_dir); + assert!(stored_path.exists()); + + // Delete it + let result = delete_image(&image_ref, &project_dir); + assert!(result.is_ok()); + assert!(!stored_path.exists()); + } + + #[test] + fn test_delete_nonexistent_image_is_ok() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let fake_ref = ImageRef::new("nonexistent.png".to_string(), "png".to_string(), None); + + // Should succeed even if file doesn't exist + let result = delete_image(&fake_ref, &project_dir); + assert!(result.is_ok()); + } + + #[test] + fn test_image_path() { + let project_dir = Path::new("/project"); + let image_ref = ImageRef::new("abc123.png".to_string(), "png".to_string(), None); + + let path = image_path(&image_ref, project_dir); + assert_eq!(path, PathBuf::from("/project/images/abc123.png")); + } +} diff --git a/tiny-workshop/game/src/room/interactable.rs b/tiny-workshop/game/src/room/interactable.rs index 8b4c978..aa9ed51 100644 --- a/tiny-workshop/game/src/room/interactable.rs +++ b/tiny-workshop/game/src/room/interactable.rs @@ -1,16 +1,19 @@ //! Interactable types for the room system //! //! Interactables are objects that can be placed on surfaces in the room. -//! Currently only StickyNote is implemented (MVP). +//! Includes sticky notes (MVP) and image stacks. //! //! # Size Limits //! //! To prevent memory issues and ensure reasonable serialization sizes: //! - Note titles: max 100 characters //! - Note content: max 10,000 characters (~10KB) +//! - Image stacks: max 50 images per stack +//! - Image labels: max 200 characters //! //! Content exceeding limits is truncated on creation. +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -23,6 +26,128 @@ pub const MAX_NOTE_TITLE_LEN: usize = 100; /// Maximum length for sticky note content (characters) pub const MAX_NOTE_CONTENT_LEN: usize = 10_000; +/// Maximum length for image labels (characters) +pub const MAX_IMAGE_LABEL_LEN: usize = 200; + +/// Maximum number of images allowed in a single stack. +/// +/// When this limit is reached, the oldest image is auto-removed +/// to make room for new images. +pub const MAX_IMAGES_PER_STACK: usize = 50; + +// ============================================================================= +// Image Stack Types +// ============================================================================= + +/// Reference to a stored image file. +/// +/// Images are copied from their source location to the project's images/ +/// directory for persistence. This struct tracks the stored copy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageRef { + /// Unique identifier for this image + pub id: Uuid, + /// Filename in the images/ directory (e.g., "abc123.png") + pub filename: String, + /// File extension without the dot (e.g., "png", "jpg") + pub extension: String, + /// Optional human-readable label for the image + #[serde(default)] + pub label: Option, + /// When the image was added to the stack + pub created_at: DateTime, +} + +impl ImageRef { + /// Create a new ImageRef with a random ID. + /// + /// The label is truncated if it exceeds [`MAX_IMAGE_LABEL_LEN`] characters. + pub fn new(filename: String, extension: String, label: Option) -> Self { + Self { + id: Uuid::new_v4(), + filename, + extension, + label: label.map(|l| truncate_string(l, MAX_IMAGE_LABEL_LEN)), + created_at: Utc::now(), + } + } +} + +/// A stack of images that can be placed on a surface. +/// +/// Images are displayed as a stack with the most recent image on top. +/// The visual representation shows only the top image as a textured quad. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageStack { + /// Unique identifier for this stack + pub id: InteractableId, + /// Images in the stack (last = top/most recent) + pub images: Vec, + /// When the stack was created + pub created_at: DateTime, +} + +impl ImageStack { + /// Create a new empty image stack with a random ID. + pub fn new() -> Self { + Self { + id: Uuid::new_v4(), + images: Vec::new(), + created_at: Utc::now(), + } + } + + /// Create a new image stack with an initial image. + pub fn with_image(image: ImageRef) -> Self { + Self { + id: Uuid::new_v4(), + images: vec![image], + created_at: Utc::now(), + } + } + + /// Add an image to the top of the stack. + /// + /// If the stack is at [`MAX_IMAGES_PER_STACK`], removes the oldest + /// (bottom) image to make room. + /// + /// Returns the removed image if one was evicted. + pub fn push_image(&mut self, image: ImageRef) -> Option { + let evicted = if self.images.len() >= MAX_IMAGES_PER_STACK { + Some(self.images.remove(0)) // Remove oldest (bottom) + } else { + None + }; + self.images.push(image); + evicted + } + + /// Get the top (most recent) image in the stack. + pub fn top_image(&self) -> Option<&ImageRef> { + self.images.last() + } + + /// Get the number of images in the stack. + pub fn image_count(&self) -> usize { + self.images.len() + } + + /// Check if the stack is empty. + pub fn is_empty(&self) -> bool { + self.images.is_empty() + } +} + +impl Default for ImageStack { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Sticky Note Types +// ============================================================================= + /// A sticky note that can be placed on a surface #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StickyNote { @@ -54,12 +179,40 @@ impl StickyNote { } /// Truncate a string to a maximum number of characters. -/// Truncates at char boundaries to avoid splitting multi-byte characters. +/// +/// This function correctly handles multi-byte UTF-8 characters by iterating +/// over Unicode scalar values (chars) rather than bytes. This ensures we never +/// truncate in the middle of a multi-byte character sequence. +/// +/// Uses `char_indices()` to efficiently find the correct byte position for +/// truncation, avoiding the need to iterate through the string multiple times. +/// +/// # Arguments +/// - `s`: The string to truncate +/// - `max_len`: Maximum number of characters (not bytes) to keep +/// +/// # Returns +/// - The original string if it's within the limit +/// - Truncated string at the character boundary fn truncate_string(s: String, max_len: usize) -> String { - if s.chars().count() <= max_len { + // Use char_indices() with enumerate() to efficiently track character count + // This avoids iterating twice (once for count() and once for take()) + let mut end_byte_pos = s.len(); + + for (char_count, (byte_pos, _char)) in s.char_indices().enumerate() { + if char_count >= max_len { + end_byte_pos = byte_pos; + break; + } + } + + // If we never broke out of the loop, the string is within the limit + if end_byte_pos == s.len() { s } else { - s.chars().take(max_len).collect() + // Truncate at the byte position we found + // This is safe because char_indices() only returns valid character boundaries + s[..end_byte_pos].to_string() } } @@ -97,7 +250,8 @@ fn default_note_color() -> NoteColor { pub enum Interactable { /// A sticky note with title and content StickyNote(StickyNote), - // Future: Image, Document, etc. + /// A stack of images (most recent on top) + ImageStack(ImageStack), } impl Interactable { @@ -105,6 +259,7 @@ impl Interactable { pub fn id(&self) -> InteractableId { match self { Interactable::StickyNote(note) => note.id, + Interactable::ImageStack(stack) => stack.id, } } @@ -117,6 +272,25 @@ impl Interactable { note.color, note.title, note.content ) } + Interactable::ImageStack(stack) => { + let count = stack.image_count(); + if let Some(top) = stack.top_image() { + let label_part = top + .label + .as_ref() + .map(|l| format!(" '{l}'")) + .unwrap_or_default(); + format!( + "Image stack{} ({} image{}, top: {})", + label_part, + count, + if count == 1 { "" } else { "s" }, + top.filename + ) + } else { + "Empty image stack".to_string() + } + } } } } @@ -226,4 +400,162 @@ mod tests { assert_eq!(note.title, "Hello"); assert_eq!(note.content, "World"); } + + // ========================================================================= + // ImageRef Tests + // ========================================================================= + + #[test] + fn test_image_ref_creation() { + let img = ImageRef::new( + "abc123.png".to_string(), + "png".to_string(), + Some("My screenshot".to_string()), + ); + assert_eq!(img.filename, "abc123.png"); + assert_eq!(img.extension, "png"); + assert_eq!(img.label, Some("My screenshot".to_string())); + } + + #[test] + fn test_image_ref_without_label() { + let img = ImageRef::new("def456.jpg".to_string(), "jpg".to_string(), None); + assert_eq!(img.filename, "def456.jpg"); + assert_eq!(img.extension, "jpg"); + assert!(img.label.is_none()); + } + + #[test] + fn test_image_ref_label_truncation() { + let long_label = "x".repeat(MAX_IMAGE_LABEL_LEN + 50); + let img = ImageRef::new("test.png".to_string(), "png".to_string(), Some(long_label)); + assert_eq!( + img.label.as_ref().unwrap().chars().count(), + MAX_IMAGE_LABEL_LEN + ); + } + + #[test] + fn test_image_ref_serialization() { + let img = ImageRef::new( + "test.png".to_string(), + "png".to_string(), + Some("Label".to_string()), + ); + let json = serde_json::to_string(&img).unwrap(); + assert!(json.contains("\"filename\":\"test.png\"")); + assert!(json.contains("\"extension\":\"png\"")); + assert!(json.contains("\"label\":\"Label\"")); + } + + // ========================================================================= + // ImageStack Tests + // ========================================================================= + + #[test] + fn test_image_stack_new() { + let stack = ImageStack::new(); + assert!(stack.is_empty()); + assert_eq!(stack.image_count(), 0); + assert!(stack.top_image().is_none()); + } + + #[test] + fn test_image_stack_with_image() { + let img = ImageRef::new("test.png".to_string(), "png".to_string(), None); + let stack = ImageStack::with_image(img); + assert!(!stack.is_empty()); + assert_eq!(stack.image_count(), 1); + assert!(stack.top_image().is_some()); + } + + #[test] + fn test_image_stack_push_image() { + let mut stack = ImageStack::new(); + let img1 = ImageRef::new("first.png".to_string(), "png".to_string(), None); + let img2 = ImageRef::new("second.png".to_string(), "png".to_string(), None); + + assert!(stack.push_image(img1).is_none()); // No eviction + assert!(stack.push_image(img2).is_none()); // No eviction + + assert_eq!(stack.image_count(), 2); + assert_eq!(stack.top_image().unwrap().filename, "second.png"); + } + + #[test] + fn test_image_stack_eviction_at_limit() { + let mut stack = ImageStack::new(); + + // Fill to capacity + for i in 0..MAX_IMAGES_PER_STACK { + let img = ImageRef::new(format!("img{i}.png"), "png".to_string(), None); + assert!(stack.push_image(img).is_none()); + } + assert_eq!(stack.image_count(), MAX_IMAGES_PER_STACK); + + // One more should evict the oldest + let new_img = ImageRef::new("new.png".to_string(), "png".to_string(), None); + let evicted = stack.push_image(new_img); + + assert!(evicted.is_some()); + assert_eq!(evicted.unwrap().filename, "img0.png"); + assert_eq!(stack.image_count(), MAX_IMAGES_PER_STACK); // Still at limit + assert_eq!(stack.top_image().unwrap().filename, "new.png"); + } + + #[test] + fn test_image_stack_describe() { + let img = ImageRef::new( + "screenshot.png".to_string(), + "png".to_string(), + Some("My capture".to_string()), + ); + let stack = ImageStack::with_image(img); + let interactable = Interactable::ImageStack(stack); + + let desc = interactable.describe(); + assert!(desc.contains("My capture")); + assert!(desc.contains("1 image")); + assert!(desc.contains("screenshot.png")); + } + + #[test] + fn test_image_stack_describe_multiple() { + let mut stack = ImageStack::new(); + for i in 0..3 { + stack.push_image(ImageRef::new( + format!("img{i}.png"), + "png".to_string(), + None, + )); + } + let interactable = Interactable::ImageStack(stack); + + let desc = interactable.describe(); + assert!(desc.contains("3 images")); + assert!(desc.contains("img2.png")); // Top image + } + + #[test] + fn test_image_stack_describe_empty() { + let stack = ImageStack::new(); + let interactable = Interactable::ImageStack(stack); + let desc = interactable.describe(); + assert_eq!(desc, "Empty image stack"); + } + + #[test] + fn test_image_stack_serialization() { + let img = ImageRef::new( + "test.png".to_string(), + "png".to_string(), + Some("Label".to_string()), + ); + let stack = ImageStack::with_image(img); + let interactable = Interactable::ImageStack(stack); + + let json = serde_json::to_string(&interactable).unwrap(); + assert!(json.contains("\"type\":\"image_stack\"")); + assert!(json.contains("\"filename\":\"test.png\"")); + } } diff --git a/tiny-workshop/game/src/room/mod.rs b/tiny-workshop/game/src/room/mod.rs index 044651c..7ce6ec7 100644 --- a/tiny-workshop/game/src/room/mod.rs +++ b/tiny-workshop/game/src/room/mod.rs @@ -27,12 +27,17 @@ #![allow(unused_imports)] pub mod error; +pub mod image_cache; +pub mod image_storage; pub mod interactable; pub mod surface; pub use error::RoomError; +pub use image_cache::{ImageCache, ImageLoadState}; +pub use image_storage::{delete_image, image_path, images_dir, store_image}; pub use interactable::{ - Interactable, InteractableId, NoteColor, StickyNote, MAX_NOTE_CONTENT_LEN, MAX_NOTE_TITLE_LEN, + ImageRef, ImageStack, Interactable, InteractableId, NoteColor, StickyNote, + MAX_IMAGES_PER_STACK, MAX_IMAGE_LABEL_LEN, MAX_NOTE_CONTENT_LEN, MAX_NOTE_TITLE_LEN, }; pub use surface::{Slot, Surface}; @@ -49,11 +54,7 @@ use std::collections::HashMap; /// to make room for new items. This prevents unbounded memory growth. pub const MAX_TOTAL_INTERACTABLES: usize = 100; -/// Maximum number of images allowed in a single stack. -/// -/// When image stacks are implemented, this limit prevents any single -/// stack from growing unboundedly. Oldest images are auto-archived. -pub const MAX_IMAGES_PER_STACK: usize = 50; +// Note: MAX_IMAGES_PER_STACK is defined in interactable.rs and re-exported above /// Manages all surfaces and interactables in the room #[derive(Debug)] @@ -132,6 +133,16 @@ impl RoomManager { from_surface, to_surface, } => self.move_note(title, from_surface, to_surface), + RoomAction::AddImage { + source_path: _, + label: _, + } => { + // TODO: Phase 2 - Implement add_image with project_dir parameter + // For now, return an error indicating not implemented + Err(RoomError::InvalidAction { + reason: "AddImage not yet implemented (requires project_dir)".to_string(), + }) + } } } @@ -305,7 +316,7 @@ impl RoomManager { /// - `Some(description)` if the note is found /// - `None` if no note with that title exists pub fn examine_note(&self, title: &str) -> Option { - for (_surface_id, surface) in &self.surfaces { + for surface in self.surfaces.values() { for (row_idx, row) in surface.slots.iter().enumerate() { for (col_idx, slot) in row.iter().enumerate() { if let Some(Interactable::StickyNote(note)) = &slot.item { @@ -364,6 +375,143 @@ impl RoomManager { }) } + /// Iterate over all image stacks in the room with their positions. + /// + /// Returns tuples of (surface_id, row, col, &ImageStack) for each stack. + /// Used for visual rendering synchronization. + pub fn iter_image_stacks(&self) -> impl Iterator { + self.surfaces.iter().flat_map(|(surface_id, surface)| { + surface + .slots + .iter() + .enumerate() + .flat_map(move |(row, row_slots)| { + row_slots.iter().enumerate().filter_map(move |(col, slot)| { + if let Some(Interactable::ImageStack(stack)) = &slot.item { + Some((surface_id.as_str(), row, col, stack)) + } else { + None + } + }) + }) + }) + } + + // ========================================================================= + // Image Stack Operations + // ========================================================================= + + /// Add an image to the room workspace. + /// + /// The image is copied from the source path to the project's images directory. + /// Images are placed on the desk surface in a stack. If no image stack exists, + /// one is created. If the desk is full, uses the first available surface. + /// + /// # Arguments + /// - `source_path` - Path to the source image file + /// - `label` - Optional human-readable label for the image + /// - `project_dir` - Path to the project directory for persistent storage + /// + /// # Returns + /// - `Ok(message)` - Success message describing what was done + /// - `Err(RoomError)` - If the image couldn't be added + pub fn add_image( + &mut self, + source_path: &std::path::Path, + label: Option, + project_dir: &std::path::Path, + ) -> Result { + // Store the image to persistent storage + let image_ref = image_storage::store_image(source_path, project_dir, label.clone())?; + + // Enforce room limits before adding + let removed_count = self.enforce_limit(); + if removed_count > 0 { + fyrox::core::log::Log::info(format!( + "Room limit reached: auto-archived {removed_count} item(s) to make space" + )); + } + + // Try to find an existing image stack on desk, or create one + // First, check if there's already an image stack on desk + if let Some(stack_pos) = self.find_image_stack_on_surface("desk") { + // Add to existing stack + let (row, col) = stack_pos; + let desk = self.surfaces.get_mut("desk").unwrap(); + if let Some(Interactable::ImageStack(stack)) = &mut desk.slots[row][col].item { + let evicted = stack.push_image(image_ref); + if let Some(evicted_img) = evicted { + // Clean up evicted image file + if let Err(e) = image_storage::delete_image(&evicted_img, project_dir) { + fyrox::core::log::Log::warn(format!("Failed to delete evicted image: {e}")); + } + } + let count = stack.image_count(); + let label_msg = label.map(|l| format!(" '{l}'")).unwrap_or_default(); + return Ok(format!( + "Added image{label_msg} to stack on Desk ({count} image{} total)", + if count == 1 { "" } else { "s" } + )); + } + } + + // No existing stack - create a new one + let stack = ImageStack::with_image(image_ref); + let label_msg = label.map(|l| format!(" '{l}'")).unwrap_or_default(); + + // Try desk first, then other surfaces + let surfaces_to_try = [ + "desk", + "windowside_table", + "bookshelf", + "mantelpiece", + "lectern", + ]; + + for surface_id in surfaces_to_try { + if let Some(surface) = self.surfaces.get_mut(surface_id) { + if let Some((row, col)) = + surface.place_auto(Interactable::ImageStack(stack.clone())) + { + return Ok(format!( + "Created image stack{} on {} at position ({}, {})", + label_msg, + surface.name, + row + 1, + col + 1 + )); + } + } + } + + // All surfaces full - this shouldn't happen after enforce_limit, but handle gracefully + // Delete the stored image since we couldn't place it + if let Err(e) = image_storage::delete_image(&stack.images[0], project_dir) { + fyrox::core::log::Log::warn(format!( + "Failed to clean up image after placement failure: {e}" + )); + } + + Err(RoomError::InvalidAction { + reason: "All surfaces are full. Cannot add image.".to_string(), + }) + } + + /// Find the position of an image stack on a surface. + /// + /// Returns (row, col) of the first image stack found, or None. + fn find_image_stack_on_surface(&self, surface_id: &str) -> Option<(usize, usize)> { + let surface = self.surfaces.get(surface_id)?; + for (row, row_slots) in surface.slots.iter().enumerate() { + for (col, slot) in row_slots.iter().enumerate() { + if matches!(&slot.item, Some(Interactable::ImageStack(_))) { + return Some((row, col)); + } + } + } + None + } + // ========================================================================= // Room Limits // ========================================================================= @@ -766,9 +914,13 @@ mod tests { let removed = manager.remove_oldest_item(); assert!(removed.is_some()); - // Currently only StickyNote variant exists, so this pattern is irrefutable - let Interactable::StickyNote(note) = removed.unwrap(); - assert_eq!(note.title, "First"); + // We know we added StickyNotes, so this match should succeed + match removed.unwrap() { + Interactable::StickyNote(note) => { + assert_eq!(note.title, "First"); + } + Interactable::ImageStack(_) => panic!("Expected StickyNote, got ImageStack"), + } assert_eq!(manager.total_interactable_count(), 1); } @@ -1027,4 +1179,223 @@ mod tests { assert!(desc2.contains("Bookshelf")); assert!(desc2.contains("Green")); } + + // ========================================================================= + // Image Stack Tests + // ========================================================================= + + #[test] + fn test_iter_image_stacks_empty() { + let manager = RoomManager::new(); + let stacks: Vec<_> = manager.iter_image_stacks().collect(); + assert!(stacks.is_empty()); + } + + #[test] + fn test_add_image_creates_stack() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + // Create a fake image file + let image_path = temp.path().join("test.png"); + fs::write(&image_path, b"fake png data").unwrap(); + + let mut manager = RoomManager::new(); + + // Add image + let result = manager.add_image(&image_path, Some("Test image".to_string()), &project_dir); + assert!(result.is_ok()); + let msg = result.unwrap(); + assert!(msg.contains("Created image stack")); + assert!(msg.contains("Test image")); + + // Verify image stack exists + let stacks: Vec<_> = manager.iter_image_stacks().collect(); + assert_eq!(stacks.len(), 1); + assert_eq!(stacks[0].0, "desk"); // Should be on desk + assert_eq!(stacks[0].3.image_count(), 1); + } + + #[test] + fn test_add_image_to_existing_stack() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + // Create two fake image files + let image1 = temp.path().join("img1.png"); + let image2 = temp.path().join("img2.png"); + fs::write(&image1, b"fake png 1").unwrap(); + fs::write(&image2, b"fake png 2").unwrap(); + + let mut manager = RoomManager::new(); + + // Add first image + manager + .add_image(&image1, Some("First".to_string()), &project_dir) + .unwrap(); + + // Add second image + let result = manager.add_image(&image2, Some("Second".to_string()), &project_dir); + assert!(result.is_ok()); + let msg = result.unwrap(); + assert!(msg.contains("Added image")); + assert!(msg.contains("2 images total")); + + // Verify still only one stack with two images + let stacks: Vec<_> = manager.iter_image_stacks().collect(); + assert_eq!(stacks.len(), 1); + assert_eq!(stacks[0].3.image_count(), 2); + } + + #[test] + fn test_add_image_file_not_found() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let missing_path = temp.path().join("missing.png"); + let mut manager = RoomManager::new(); + + let result = manager.add_image(&missing_path, None, &project_dir); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RoomError::ImageNotFound { .. } + )); + } + + #[test] + fn test_add_image_invalid_format() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + // Create a file with invalid extension + let text_file = temp.path().join("document.txt"); + fs::write(&text_file, b"not an image").unwrap(); + + let mut manager = RoomManager::new(); + let result = manager.add_image(&text_file, None, &project_dir); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RoomError::InvalidImageFormat { .. } + )); + } + + #[test] + fn test_find_image_stack_on_surface() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let image_path = temp.path().join("test.png"); + fs::write(&image_path, b"fake png").unwrap(); + + let mut manager = RoomManager::new(); + + // Initially no stack + assert!(manager.find_image_stack_on_surface("desk").is_none()); + + // Add image + manager.add_image(&image_path, None, &project_dir).unwrap(); + + // Now should find stack + let pos = manager.find_image_stack_on_surface("desk"); + assert!(pos.is_some()); + } + + #[test] + fn test_end_to_end_image_workflow() { + use std::fs; + use std::io::Write; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + + let mut manager = RoomManager::new(); + + // Step 1: Create a test image file + let image1_path = temp.path().join("test1.png"); + let mut file1 = fs::File::create(&image1_path).unwrap(); + // Write minimal PNG data (1x1 transparent pixel) + let png_data = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // bit depth, color type, etc. + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk start + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, // compressed data + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, // end of IDAT + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk + 0xAE, 0x42, 0x60, 0x82, // IEND CRC + ]; + file1.write_all(&png_data).unwrap(); + + // Step 2: Add first image + let result1 = + manager.add_image(&image1_path, Some("Test Image 1".to_string()), &project_dir); + assert!(result1.is_ok(), "First image should be added successfully"); + let msg1 = result1.unwrap(); + assert!( + msg1.contains("Created image stack"), + "Should create new stack" + ); + assert!(msg1.contains("'Test Image 1'"), "Should include label"); + + // Verify image was stored to disk + let images_dir = images_dir(&project_dir).expect("images directory should exist"); + assert!(images_dir.exists(), "images directory should exist"); + + // Step 3: Create second image and add to same stack + let image2_path = temp.path().join("test2.png"); + let mut file2 = fs::File::create(&image2_path).unwrap(); + file2.write_all(&png_data).unwrap(); + + let result2 = + manager.add_image(&image2_path, Some("Test Image 2".to_string()), &project_dir); + assert!(result2.is_ok(), "Second image should be added successfully"); + let msg2 = result2.unwrap(); + assert!(msg2.contains("Added image"), "Should add to existing stack"); + assert!(msg2.contains("'Test Image 2'"), "Should include label"); + + // Step 4: Verify room state + let desk = manager.surfaces.get("desk").unwrap(); + let slot = &desk.slots[0][0]; + assert!(slot.item.is_some(), "Desk slot (0,0) should have an item"); + + if let Some(Interactable::ImageStack(stack)) = &slot.item { + assert_eq!(stack.image_count(), 2, "Stack should have 2 images"); + assert_eq!(stack.images[0].label, Some("Test Image 1".to_string())); + assert_eq!(stack.images[1].label, Some("Test Image 2".to_string())); + } else { + panic!("Expected ImageStack at (0,0)"); + } + + // Step 5: Verify total count + assert_eq!( + manager.total_interactable_count(), + 1, + "Should have 1 interactable (the stack)" + ); + } }