jigsawR / docs /issue-40-implementation-plan.md
pjt222's picture
Upload folder using huggingface_hub
e232e39 verified

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

#' 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:

# 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:

#' 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():

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():

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:

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:

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:

#' 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):

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

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()

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
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

# 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

# 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

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

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:

# 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".

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))
# 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
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