# Issue #40: Meta Pieces Implementation Plan ## Overview Implement the ability to fuse two or more adjacent pieces into a single "meta piece" that behaves as one unit for rendering and separation. ## Architecture Decision **Approach: Generation-Time Edge Skipping** After investigation, the recommended approach is to skip internal edge generation for fused piece pairs during the puzzle generation phase, rather than trying to hide edges at render time. **Rationale:** 1. Edges are embedded as bezier curves in piece SVG paths 2. Simply hiding edges would leave deformed piece boundaries (tabs with nothing to connect to) 3. Generation-time fusion creates clean merged boundaries 4. Works naturally with existing separation/offset mechanism ## Implementation Phases ### Phase 1: Unified Adjacency API **Goal:** Create a consistent way to query piece neighbors across all puzzle types. **New File:** `R/adjacency_api.R` ```r #' Get neighbors of a piece #' @param piece_id Piece identifier (string or integer depending on type) #' @param puzzle_result Output from generate_puzzle() #' @return Data frame with columns: direction, neighbor_id, is_boundary, shared_edge_key #' @export get_piece_neighbors <- function(piece_id, puzzle_result) #' Check if two pieces are adjacent #' @export are_pieces_adjacent <- function(piece_id_a, piece_id_b, puzzle_result) #' Get the shared edge key between two adjacent pieces #' @export get_shared_edge_key <- function(piece_id_a, piece_id_b, puzzle_result) #' Validate a fusion group (all pieces must be connected) #' @export validate_fusion_group <- function(piece_ids, puzzle_result) ``` **Type-specific implementations:** - `get_rect_neighbors()` - Grid-based (new) - `get_hex_neighbors_unified()` - Wrap existing `get_hex_neighbor()` - `get_concentric_neighbors_unified()` - Wrap existing `get_concentric_neighbor()` **Direction Naming Convention:** | Rectangular | Hexagonal | Concentric | |-------------|-----------|------------| | N, E, S, W | 0-5 (sides) | INNER, RIGHT, OUTER, LEFT | --- ### Phase 2: Edge Fusion Mechanism **Goal:** Track which edges should be skipped during generation. **Data Structures:** ```r # Fusion specification (user input) fusion_groups <- list( c("piece_0_0", "piece_1_0"), # Rectangular: fuse two horizontal neighbors c("piece_0_0", "piece_1_0", "piece_0_1", "piece_1_1") # 2x2 block ) # Internal representation fused_edges <- list( "piece_0_0-E" = TRUE, # Skip piece_0_0's east edge "piece_1_0-W" = TRUE # Skip piece_1_0's west edge (complementary) ) ``` **New Functions:** ```r #' Convert fusion groups to fused edge set #' @param fusion_groups List of piece ID vectors to fuse #' @param puzzle_result Puzzle structure with adjacency info #' @return Set of edge keys to skip during generation compute_fused_edges <- function(fusion_groups, puzzle_result) #' Check if an edge should be fused (skipped) #' @param piece_id Current piece #' @param direction Edge direction #' @param fused_edges Set from compute_fused_edges() #' @return TRUE if edge should be skipped is_edge_fused <- function(piece_id, direction, fused_edges) ``` --- ### Phase 3: Update Piece Generation **Goal:** Modify generation to skip fused edges and create merged boundaries. #### 3.1 Rectangular (`R/puzzle_core_clean.R`) **Modify `generate_all_edges()`:** ```r generate_all_edges <- function(xn, yn, ..., fused_edges = NULL) { # For each potential edge: edge_key <- sprintf("piece_%d_%d-%s", xi, yi, direction) if (!is.null(fused_edges) && edge_key %in% fused_edges) { # Skip this edge - pieces will share a straight boundary edges[[...]] <- NULL } else { # Generate normal bezier edge edges[[...]] <- generate_edge_segment(...) } } ``` **Modify `generate_single_piece()`:** ```r generate_single_piece <- function(xi, yi, puzzle_structure, fused_edges = NULL) { # When building path, check fused_edges: edge_key <- sprintf("piece_%d_%d-E", xi, yi) if (!is.null(fused_edges) && edge_key %in% fused_edges) { # Don't close the path on this side - continue to fused neighbor # This requires knowing the full fusion group } } ``` **Challenge:** Fused pieces need merged paths, not separate paths. **Solution:** Generate meta-pieces as single units: ```r generate_meta_piece <- function(piece_ids, puzzle_structure, fused_edges) { # 1. Get bounding box of all pieces in group # 2. Walk the outer boundary (skip internal edges) # 3. Return single merged SVG path } ``` #### 3.2 Hexagonal (`R/hexagonal_edge_generation_fixed.R`) **Modify edge map generation:** ```r generate_hex_edge_map <- function(..., fused_edges = NULL) { # When building edge_map: for (piece_id in 1:num_pieces) { for (side in 0:5) { edge_key <- sprintf("%d-%d", piece_id, side) if (!is.null(fused_edges) && edge_key %in% fused_edges) { # Mark as internal fused edge - will be skipped in path building edge_map[[edge_key]]$fused <- TRUE } } } } ``` #### 3.3 Concentric (`R/concentric_edge_generation.R`) Similar approach to hexagonal - mark fused edges in edge map. --- ### Phase 4: Meta-Piece Path Generation **Goal:** Create unified paths for fused piece groups. **Algorithm for Merged Boundary:** 1. Identify all pieces in fusion group 2. Find the outer boundary vertices (vertices not shared only within the group) 3. Walk the boundary in order, collecting edge segments 4. Skip any edge that connects two pieces within the group 5. Return single closed SVG path **New Function:** ```r #' Generate merged path for a fusion group #' @param fusion_group Vector of piece IDs to merge #' @param puzzle_structure Full puzzle with edges #' @param fused_edges Edge skip set #' @return Single SVG path string for the meta-piece generate_meta_piece_path <- function(fusion_group, puzzle_structure, fused_edges) ``` **Boundary Walking Algorithm (Rectangular):** ```r walk_fusion_boundary <- function(fusion_group, xn, yn) { # 1. Find all grid positions in group positions <- lapply(fusion_group, parse_piece_id) # -> list of c(xi, yi) # 2. Find bounding rectangle min_x <- min(sapply(positions, `[`, 1)) max_x <- max(sapply(positions, `[`, 1)) min_y <- min(sapply(positions, `[`, 2)) max_y <- max(sapply(positions, `[`, 2)) # 3. Walk perimeter, only including edges that face outward boundary_edges <- list() for each edge on perimeter: if neighbor is NOT in fusion_group: add edge to boundary_edges # 4. Assemble into single path return(assemble_boundary_path(boundary_edges)) } ``` --- ### Phase 5: Integration with Unified Pipeline **Goal:** Wire meta-pieces through the existing pipeline. #### 5.1 Update `generate_puzzle()` API ```r generate_puzzle <- function( type = "rectangular", ..., fusion_groups = NULL, # NEW: List of piece ID vectors to fuse vanish_internal = TRUE # NEW: Whether fused edges disappear (vs dashed lines) ) { # Validate fusion groups if (!is.null(fusion_groups)) { fusion_groups <- validate_fusion_groups(fusion_groups, type, grid) } # Pass to internal generation pieces_result <- generate_pieces_internal( ..., fusion_groups = fusion_groups, vanish_internal = vanish_internal ) } ``` #### 5.2 Update `generate_pieces_internal()` ```r generate_pieces_internal <- function(..., fusion_groups = NULL, vanish_internal = TRUE) { # Compute fused edges from fusion groups if (!is.null(fusion_groups)) { fused_edges <- compute_fused_edges(fusion_groups, type, grid) } # Pass to type-specific generator if (type == "rectangular") { result <- generate_rectangular_pieces_with_fusion( ..., fusion_groups = fusion_groups, fused_edges = fused_edges ) } # ... etc for other types } ``` #### 5.3 Update Piece List Structure When fusion is active, the piece list changes: - Individual pieces in fusion groups are replaced by single meta-pieces - Meta-piece inherits properties: - `id`: Combined ID like `"meta_0_0+1_0"` or `"meta_1"` - `path`: Merged boundary path - `center`: Centroid of all constituent pieces - `constituent_pieces`: List of original piece IDs (for reference) - `type`: Same as puzzle type ```r meta_piece <- list( id = "meta_1", path = "M 0 0 L 200 0 C ... Z", # Merged outer boundary center = c(100, 50), constituent_pieces = c("piece_0_0", "piece_1_0"), type = "rectangular" ) ``` #### 5.4 Update Positioning `apply_piece_positioning()` should handle meta-pieces naturally: - Meta-piece has single center → translates as unit - Offset calculation uses meta-piece position, not constituent positions --- ### Phase 6: Shiny UI Integration **Goal:** Allow users to select pieces to fuse in the Shiny app. #### 6.1 UI Components ```r # In UI: Add fusion controls panel fusion_panel <- conditionalPanel( condition = "input.enable_fusion", checkboxInput("enable_fusion", "Enable Piece Fusion", FALSE), # Option 1: Predefined patterns selectInput("fusion_pattern", "Fusion Pattern", choices = c("None", "2x2 Corners", "Center Block", "Custom") ), # Option 2: Click-to-select (advanced) # Would require interactive SVG with piece selection # Preview of fusion groups verbatimTextOutput("fusion_preview") ) ``` #### 6.2 Server Logic ```r # In server: Handle fusion fusion_groups <- reactive({ if (!input$enable_fusion) return(NULL) if (input$fusion_pattern == "2x2 Corners") { # Generate corner fusion groups based on grid size generate_corner_fusion(input$rows, input$cols) } else if (input$fusion_pattern == "Custom") { # Parse custom input parse_custom_fusion(input$custom_fusion_text) } }) # Pass to generate_puzzle() puzzle <- generate_puzzle( ..., fusion_groups = fusion_groups() ) ``` --- ### Phase 7: Testing Strategy #### 7.1 Unit Tests **File:** `tests/testthat/test-adjacency-api.R` ```r test_that("rectangular adjacency works", { result <- generate_puzzle(type = "rectangular", grid = c(3, 3)) neighbors <- get_piece_neighbors("piece_1_1", result) expect_equal(nrow(neighbors), 4) # Center piece has 4 neighbors }) test_that("are_pieces_adjacent works", { result <- generate_puzzle(type = "rectangular", grid = c(2, 2)) expect_true(are_pieces_adjacent("piece_0_0", "piece_1_0", result)) expect_false(are_pieces_adjacent("piece_0_0", "piece_1_1", result)) # Diagonal }) ``` **File:** `tests/testthat/test-meta-pieces.R` ```r test_that("simple 2-piece fusion works for rectangular", { result <- generate_puzzle( type = "rectangular", grid = c(2, 2), fusion_groups = list(c("piece_0_0", "piece_1_0")) ) expect_equal(length(result$pieces), 3) # 4 - 2 + 1 meta = 3 }) test_that("meta piece path is valid closed path", { result <- generate_puzzle( type = "rectangular", grid = c(2, 2), fusion_groups = list(c("piece_0_0", "piece_1_0")) ) meta <- result$pieces[[1]] # Assuming first expect_true(grepl("^M.*Z$", meta$path)) # Starts with M, ends with Z }) test_that("fusion works with separation", { result <- generate_puzzle( type = "rectangular", grid = c(2, 2), fusion_groups = list(c("piece_0_0", "piece_1_0")), offset = 10 ) # Meta piece should be separated from others expect_true(length(result$pieces) == 3) }) ``` #### 7.2 Visual Tests Create test script that generates visual outputs: ```r # tests/visual/test-fusion-visual.R # Test 1: 2x2 rectangular with horizontal fusion generate_puzzle( type = "rectangular", grid = c(2, 2), fusion_groups = list(c("piece_0_0", "piece_1_0")), save_files = TRUE, output_dir = "tests/visual/output" ) # Test 2: 3x3 rectangular with 2x2 block fusion generate_puzzle( type = "rectangular", grid = c(3, 3), fusion_groups = list(c("piece_0_0", "piece_1_0", "piece_0_1", "piece_1_1")), save_files = TRUE ) # Test 3: Hexagonal with adjacent fusion generate_puzzle( type = "hexagonal", grid = c(3), fusion_groups = list(c(1, 2)), # Center + one ring-1 piece save_files = TRUE ) ``` --- ## File Changes Summary | File | Change Type | Description | |------|-------------|-------------| | `R/adjacency_api.R` | **NEW** | Unified adjacency API | | `R/puzzle_core_clean.R` | MODIFY | Add fusion support to rectangular generation | | `R/hexagonal_edge_generation_fixed.R` | MODIFY | Add fusion support to hexagonal | | `R/concentric_edge_generation.R` | MODIFY | Add fusion support to concentric | | `R/unified_piece_generation.R` | MODIFY | Pass fusion params, generate meta-pieces | | `R/piece_positioning.R` | MINOR | Handle meta-piece positioning (should work as-is) | | `R/unified_renderer.R` | MINOR | Handle meta-piece rendering (should work as-is) | | `R/jigsawR_clean.R` | MODIFY | Add `fusion_groups` and `vanish_internal` params | | `inst/shiny-app/app.R` | MODIFY | Add fusion UI controls | | `tests/testthat/test-adjacency-api.R` | **NEW** | Adjacency API tests | | `tests/testthat/test-meta-pieces.R` | **NEW** | Meta-piece fusion tests | --- ## Implementation Order ### Sprint 1: Foundation (Adjacency API) 1. Create `R/adjacency_api.R` with type dispatch 2. Implement `get_rect_neighbors()` (new) 3. Wrap `get_hex_neighbors_unified()` 4. Wrap `get_concentric_neighbors_unified()` 5. Add unit tests ### Sprint 2: Rectangular Fusion 1. Implement `compute_fused_edges()` for rectangular 2. Modify `generate_all_edges()` to skip fused edges 3. Implement `generate_meta_piece_path()` for rectangular 4. Update `generate_pieces_internal()` for rectangular fusion 5. Add tests and visual validation ### Sprint 3: Hexagonal & Concentric Fusion 1. Extend fusion to hexagonal puzzles 2. Extend fusion to concentric puzzles 3. Handle complex cases (ring boundaries, angular overlaps) 4. Add tests ### Sprint 4: API & Shiny Integration 1. Add `fusion_groups` parameter to `generate_puzzle()` 2. Add Shiny UI for fusion selection 3. Add predefined fusion patterns 4. Final testing and documentation --- ## Design Decisions (Resolved) ### 1. Internal Edge Style (Configurable) The appearance of internal edges within fused groups is configurable: | Style | Description | |-------|-------------| | `"none"` | Internal edges completely vanish (default) | | `"dashed"` | Internal edges shown as dashed lines | | `"solid"` | Internal edges shown as solid lines | Additionally, an **opacity slider** (0.0 - 1.0) controls the transparency of internal edges when style is "dashed" or "solid". ```r generate_puzzle( ..., fusion_groups = list(c(1, 2)), fusion_style = "dashed", # "none", "dashed", "solid" fusion_opacity = 0.3 # 0.0 (invisible) to 1.0 (fully visible) ) ``` ### 2. Validation Rules - **Adjacency Required**: All pieces in a fusion group must be connected (each piece adjacent to at least one other in the group) - **No Overlapping Groups**: A piece can only belong to ONE fusion group - **No Maximum Size**: Fusion groups can be any size (up to all pieces) ### 3. Input Format Simple, flexible input format: - Single group: `"1,2"` → fuse pieces 1 and 2 - Multiple groups: `"(1,2),(7,8,9)"` → fuse 1+2 and separately fuse 7+8+9 - Programmatic: `list(c(1, 2), c(7, 8, 9))` ```r # String input (Shiny-friendly) fusion_groups = "(1,2),(7,8,9)" # List input (programmatic) fusion_groups = list(c(1, 2), c(7, 8, 9)) ``` ### 4. Meta-Piece Naming Simple sequential naming: - `meta_1`, `meta_2`, `meta_3`, ... - Original piece IDs stored in `constituent_pieces` attribute ```r meta_piece <- list( id = "meta_1", path = "M 0 0 L 200 0 ...", center = c(100, 50), constituent_pieces = c(1, 2), # Original piece IDs type = "rectangular" ) ``` ### 5. Separation Behavior - Fused pieces **stay together** when offset > 0 (move as single unit) - No "explode" option needed (user can simply not fuse if they want separation) --- ## Success Criteria - [ ] Unified adjacency API works for all puzzle types - [ ] Two adjacent rectangular pieces can be fused - [ ] 2x2 block of rectangular pieces can be fused - [ ] Meta-piece paths are valid closed SVG paths - [ ] Meta-pieces separate correctly with offset - [ ] Hexagonal adjacent pieces can be fused - [ ] Concentric adjacent pieces can be fused - [ ] Shiny app has basic fusion controls - [ ] All tests pass - [ ] Visual output matches expected appearance