From 7266b4f0046d598bfd5896a7fc32f7276ad6d22d Mon Sep 17 00:00:00 2001 From: Gilles Colling Date: Wed, 22 Jul 2026 13:12:31 +0200 Subject: [PATCH 1/4] fix R CMD check for bijective Z7 indices --- R/hexify_index.R | 14 ++--- man/hexify_z7_canonical.Rd | 12 ++-- src/index_z7.cpp | 4 +- tests/testthat/fixtures/test_cache.rds | Bin 1077 -> 1125 bytes tests/testthat/generate_cache.R | 2 +- tests/testthat/test-index-z7.R | 84 ++++++------------------- 6 files changed, 34 insertions(+), 82 deletions(-) diff --git a/R/hexify_index.R b/R/hexify_index.R index 3e247b1..e8db79a 100644 --- a/R/hexify_index.R +++ b/R/hexify_index.R @@ -292,22 +292,20 @@ hexify_cell_id_to_quad_ij <- function(cell_id, resolution, aperture) { #' Get canonical form of Z7 index #' -#' For Z7 indices that form cycles during decode/encode, returns the -#' lexicographically smallest index in the cycle. Provides stable -#' unique identifiers for aperture 7 grids. +#' Returns the stable form of a Z7 index after decode/encode. Z7 indices are +#' bijective, so valid indices are already canonical. #' #' @param index Z7 index string -#' @param max_iterations Maximum iterations for cycle detection (default 128) +#' @param max_iterations Maximum decode/encode iterations (default 128) #' -#' @return Canonical form (lexicographically smallest in cycle) +#' @return Stable canonical form of the index #' #' @family hierarchical index #' @keywords internal #' @export #' @examples -#' # These all return the same canonical form -#' hexify_z7_canonical("110001") -#' hexify_z7_canonical("110002") +#' # Valid Z7 indices are stable +#' hexify_z7_canonical("110001") hexify_z7_canonical <- function(index, max_iterations = 128L) { cpp_z7_canonical_form(as.character(index), as.integer(max_iterations)) } diff --git a/man/hexify_z7_canonical.Rd b/man/hexify_z7_canonical.Rd index aaf65df..58e3f40 100644 --- a/man/hexify_z7_canonical.Rd +++ b/man/hexify_z7_canonical.Rd @@ -9,20 +9,18 @@ hexify_z7_canonical(index, max_iterations = 128L) \arguments{ \item{index}{Z7 index string} -\item{max_iterations}{Maximum iterations for cycle detection (default 128)} +\item{max_iterations}{Maximum decode/encode iterations (default 128)} } \value{ -Canonical form (lexicographically smallest in cycle) +Stable canonical form of the index } \description{ -For Z7 indices that form cycles during decode/encode, returns the -lexicographically smallest index in the cycle. Provides stable -unique identifiers for aperture 7 grids. +Returns the stable form of a Z7 index after decode/encode. Z7 indices are +bijective, so valid indices are already canonical. } \examples{ -# These all return the same canonical form +# Valid Z7 indices are stable hexify_z7_canonical("110001") -hexify_z7_canonical("110002") } \seealso{ Other hierarchical index: diff --git a/src/index_z7.cpp b/src/index_z7.cpp index 22a6feb..78c7626 100644 --- a/src/index_z7.cpp +++ b/src/index_z7.cpp @@ -492,8 +492,8 @@ std::string canonical_form(const std::string& z7_index, int max_iterations) { long long i, j; int res = current.length() - 2; - decode(current, res, quadNum, i, j); - std::string next = encode(quadNum, i, j, res); + decode_bijective(current, res, quadNum, i, j); + std::string next = encode_bijective(quadNum, i, j, res); // Check for fixed point if (next == current) { diff --git a/tests/testthat/fixtures/test_cache.rds b/tests/testthat/fixtures/test_cache.rds index 7592361cc779cf5e5bd6c465c51bf7cdd344f5d3..b304033d0fb5995a765bdc84cd4e7943b538fc78 100644 GIT binary patch literal 1125 zcmV-r1e*IFiwFP!0000028-ZgU|?WoU||E2tUx9MYiNj@t_1@FlQ59O38g_Yd?4E4 zFJA#jmBZg-AHej#7YYzQg9DiU^N#@v!0Pv(j*m_eQOC zU>2R1A*9HBfM-Sc;a_SlaP!?-Yp-n26m#(Sb60a(#zY4Xl}mgatt}27)w%`mjSo7w zI6eJrzWzHPIs z;Lsodp{GY6rz+x6Rq$pw<8qV?SW$@#-sQP(ED0yQS8Jw5YQV9*?{V(r>;v zpnqN=sZsU|ng^I+Q3#5PVkn0h5k<_Pn8D_}1!WLtE!YR4cT}S|&;IA^L*@BU0Y>+jliCMX?Ej$dP`domA&|P{_?KVpzvb`u+HsG=fhBv6sdYx`{@+*3W6#%U zg3PN?7CH|yM<;RPeg{UrXvqTS9S7L^Z_ne?s{omI;@{&9VEebd=mOhw{PP8{Icdde zAa!%E-%5wM)Bdw}{k^V47yHMXm>OC=ZrNXte0w2GqaMx&%0Ikjd=|v7=bx5uyXZn> zEZ4b<`$6hFCe4TPFM;G6Kgv$nyXZncpM}|${Sf}d{SbXcFmZc`zC|GMx`Rt5?+2QH z|6&CbhZb4CgR(x81QF&q!y!rxVIZ#zq rl3E1I==_;^DXA6lRmSnfiMa(iupG$_3WE5|ERf3qq!ZNmTL=IECQ2py literal 1077 zcmV-51j_p#iwFP!0000028-ZgU|?WoU}0rqU}gm}8CXL@+;lA%7?^~C98M?=lHmi< z4uAOyK&l-69{T{M|GiLv@EIJy^q+qWPyklH|8$Jp2lIug4$9k$1OpPEI~Zio+q^ew ztpl^@ybK{l<^wz{!VmvabAg-h)>?aId#0F!$Dg~J(=sMHc&J?B<7jPh@Tk@;cyD~r z!Nuw6XY=*nfogeym=TCU9uNStSwQY(U^Hf6U|~WMV9iU+O)ZA1VucE_C#UA*#Al{J zxjbNQa%x_DPJSMySYioOjH@WMI6tSfBonTRBe5X0sHC(g70CVv4K5Y}&I9|E(V^>K z;_^p_%He$bsmua8;%*S!g;2RzE7_Mlenf_`>~>o1Nu_a zb+yj$*=u-3zhjlDziOH~ZC6Jk%Uz`|UQk0pP zm!6XvUx7os65Na-{rjvc&2Yg# z_J7>0_FXJoc7V}6=A`z475hJ^JCrWJbO@v_IsWBW`)~RCy>{H=aA3)vV``m|y8rhT z^Vst>njrIPl!eZN%+X2QxZi=1FIuv|dB*|v{@e5T^eRB+o%r{71K9p;FS@|?9RGX) zY))FS8c5yT>$lQj?zI2xU4O4D(Z&ApCZ>i~k6ZTFBi~*K)2N5@f$|Tp8J`95>-neU z+b+5g8OwF<;(m}ik4f{P{7WGD#*eZS_Aa{6&u3w_Wj};JaX&;~5lq}3qHhsMyzbzV z$@_uk-@jPF1fn1G%L;^ks?T5Id4lSOTaHRy)qAxiou^3kL!34Qd5=#>G(u#nk zBARl}qWp3_SZN6=z?eW8|NsC0Zy+&?VhpQ+fq{`BG`g9g8kiX%r5+6PLB$|BL;y;g zpd?LZhlmD= 0L || result_neg$j >= 0L, - info = "Negative coords should be handled") + expect_equal(c(result_neg$i, result_neg$j), c(-1, -1), + info = "Negative coords should round-trip") }) test_that("Z7: Resolution progression works correctly", { @@ -217,52 +204,21 @@ test_that("Z7: Resolution progression works correctly", { expect_equal(nchar(idx_r3), 2L + 3L) # Face + 3 digits }) -test_that("Z7: Known problem indices behave as expected", { - # Test specific indices that we know don't round-trip - # These are not bugs but expected behavior for aperture 7 - - # Face 1 with digit 2 should re-encode to digit 6 after remapping - result <- hexify_index_to_cell("012", 7L, "z7") - re_encoded <- hexify_cell_to_index(result$face, result$i, result$j, - result$resolution, 7L, "z7") - expect_equal(re_encoded, "016", - info = "012 should re-encode to 016 (DGGRID behavior)") - - # The 110001 cycle - each re-encodes to the next in cycle - cycle_test <- list( - list(idx = "110001", expected = "110002"), - list(idx = "110002", expected = "110004"), - list(idx = "110004", expected = "110006"), - list(idx = "110006", expected = "110001") # Completes cycle - ) - - for (tc in cycle_test) { - result <- hexify_index_to_cell(tc$idx, 7L, "z7") +test_that("Z7: Former DGGRID problem indices are stable", { + problem_indices <- c("012", "110001", "110002", "110004", "110006") + + for (idx in problem_indices) { + result <- hexify_index_to_cell(idx, 7L, "z7") re_encoded <- hexify_cell_to_index(result$face, result$i, result$j, result$resolution, 7L, "z7") - expect_equal(re_encoded, tc$expected, - info = sprintf("%s should re-encode to %s (part of cycle)", - tc$idx, tc$expected)) + expect_equal(re_encoded, idx, + info = sprintf("%s should round-trip", idx)) } }) test_that("Z7: Canonical forms provide stability", { - # Test canonical forms - provides stable unique identifiers for cells - - # The 110001 cycle - all should have same canonical form - cycle_indices <- c("110001", "110002", "110004", "110006") - canonicals <- vapply(cycle_indices, hexify_z7_canonical, character(1)) - expect_true(all(canonicals == "110001"), - info = "All cycle members should canonicalize to 110001") - - # Test other known transformations - expect_equal(hexify_z7_canonical("012"), "016", - info = "012 should canonicalize to 016") - expect_equal(hexify_z7_canonical("0955555"), "0911111", - info = "0955555 should canonicalize to 0911111") - - # Canonical forms should be stable - for (idx in c("110001", "016", "0911111")) { + for (idx in c("012", "110001", "110002", "110004", "110006", "0955555")) { + expect_equal(hexify_z7_canonical(idx), idx) canonical <- hexify_z7_canonical(idx) result <- hexify_index_to_cell(canonical, 7L, "z7") re_encoded <- hexify_cell_to_index(result$face, result$i, result$j, From b156c61f6a31e927167c702463eb013adc6bc307 Mon Sep 17 00:00:00 2001 From: Gilles Colling Date: Wed, 22 Jul 2026 13:19:27 +0200 Subject: [PATCH 2/4] release v0.7.4 --- DESCRIPTION | 2 +- NEWS.md | 12 ++++-- R/grid_helpers.R | 2 +- R/hexify_index.R | 77 +++++++++++++++++++++++++---------- man/hexify_cell_to_index.Rd | 19 +++++++-- man/hexify_index_to_cell.Rd | 11 ++++- man/hexify_index_to_lonlat.Rd | 6 ++- man/hexify_lonlat_to_index.Rd | 8 +++- man/hexify_z7_canonical.Rd | 21 +++++++--- vignettes/theory.Rmd | 27 +++++++++++- 10 files changed, 142 insertions(+), 43 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index bdff7e5..642efcf 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: hexify Title: Equal-Area Hex Grids on the 'Snyder' 'ISEA' 'Icosahedron' -Version: 0.7.3.9000 +Version: 0.7.4 Authors@R: person("Gilles", "Colling", , "gilles.colling051@gmail.com", role = c("aut", "cre", "cph"), comment = c(ORCID = "0000-0003-3070-6066")) diff --git a/NEWS.md b/NEWS.md index eafdca2..9bf942c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,12 @@ -# hexify 0.7.4 (development version) - -## New features +# hexify 0.7.4 + +## Documentation + +* Documented the bijective aperture-7 Z7 format, exact round-trip guarantee, + index structure, and the limited pentagon-region difference from DGGRID's + non-injective raw Z7 encoding. + +## New features * Hierarchical navigation now works for mixed aperture `"4/3"` (ISEA43H) grids: `get_parent()`, `get_children()`, and `cell_to_index()` no longer error on diff --git a/R/grid_helpers.R b/R/grid_helpers.R index c0b7acf..eb6612d 100644 --- a/R/grid_helpers.R +++ b/R/grid_helpers.R @@ -8,7 +8,7 @@ #' Normalize antimeridian-crossing polygon coordinates #' #' Shifts longitudes of a ring's coordinate matrix so an antimeridian-crossing -#' polygon becomes contiguous instead of spanning nearly the full [-180, 180] +#' polygon becomes contiguous instead of spanning nearly the full -180 to 180 #' range. Downstream `sf::st_wrap_dateline()` then splits it correctly for #' flat-map rendering. #' diff --git a/R/hexify_index.R b/R/hexify_index.R index e8db79a..a9a3b4d 100644 --- a/R/hexify_index.R +++ b/R/hexify_index.R @@ -15,8 +15,8 @@ #' Convert cell coordinates to index string #' -#' Converts DGGRID cell coordinates (face, i, j) to a hierarchical index string. -#' The index type is automatically selected based on aperture unless specified. +#' Converts cell coordinates (`face`, `i`, `j`) to a hierarchical index string. +#' The index type is selected from `aperture` when `index_type = "auto"`. #' #' @param face Face/quad number (0-19) #' @param i I coordinate @@ -25,19 +25,30 @@ #' @param aperture Aperture (3, 4, or 7) #' @param index_type Index encoding: "auto" (default), "z3", "z7", or "zorder" #' -#' @return Index string (e.g., "051223") +#' @return A character vector of index strings. #' #' @details #' Default index types by aperture: #' - Aperture 3: Z3 (optimized digit selection) #' - Aperture 4: Z-order (Morton curve) -#' - Aperture 7: Z7 (hierarchical with Class III handling) +#' - Aperture 7: Z7 (one base-7 child digit per resolution) +#' +#' A Z7 index has the form `BBd1...dr`, where `BB` is the two-digit base +#' cell (00--11), `r` is the resolution, and every child digit is in 0--6. +#' hexify's Z7 encoding is bijective: decoding and re-encoding a valid index +#' returns the same string. It follows DGGRID's Z7 layout for ordinary cells, +#' but retains distinct indices in pentagon regions where DGGRID's encoder can +#' map different cells to the same string. #' #' @family hierarchical index #' @keywords internal #' @export -#' @examples -#' idx <- hexify_cell_to_index(5, 10, 15, resolution = 3, aperture = 3) +#' @examples +#' idx <- hexify_cell_to_index(5, 10, 15, resolution = 3, aperture = 3) +#' +#' # Aperture-7 indices contain a two-digit base cell and one digit per level +#' z7 <- hexify_cell_to_index(5, 1, 1, resolution = 2, aperture = 7) +#' nchar(z7) == 4L hexify_cell_to_index <- function(face, i, j, resolution, aperture = 3L, index_type = c("auto", "z3", "z7", "zorder")) { index_type <- match.arg(index_type) @@ -51,19 +62,26 @@ hexify_cell_to_index <- function(face, i, j, resolution, aperture = 3L, #' Convert index string to cell coordinates #' -#' Decodes a hierarchical index string back to cell coordinates. +#' Decodes a hierarchical index string back to its cell coordinates and +#' resolution. For Z7, valid indices round-trip exactly through +#' [hexify_cell_to_index()]. #' #' @param index Index string #' @param aperture Aperture (3, 4, or 7) #' @param index_type Index encoding used. Default "auto" infers from aperture. #' -#' @return List with face, i, j, and resolution +#' @return A list with `face`, `i`, `j`, and `resolution`. #' #' @family hierarchical index #' @keywords internal #' @export -#' @examples -#' cell <- hexify_index_to_cell("0012012", aperture = 3) +#' @examples +#' cell <- hexify_index_to_cell("0012012", aperture = 3) +#' +#' z7_cell <- hexify_index_to_cell("110001", aperture = 7) +#' hexify_cell_to_index(z7_cell$face, z7_cell$i, z7_cell$j, +#' z7_cell$resolution, aperture = 7 +#' ) hexify_index_to_cell <- function(index, aperture = 3L, index_type = c("auto", "z3", "z7", "zorder")) { index_type <- match.arg(index_type) @@ -73,7 +91,8 @@ hexify_index_to_cell <- function(index, aperture = 3L, #' Convert longitude/latitude to index string #' -#' Main entry point for geocoding points to grid cells. +#' Projects geographic coordinates to grid cells and returns their hierarchical +#' index strings. Inputs are vectorized over `lon` and `lat`. #' #' @param lon Longitude in degrees #' @param lat Latitude in degrees @@ -81,13 +100,16 @@ hexify_index_to_cell <- function(index, aperture = 3L, #' @param aperture Aperture (3, 4, or 7) #' @param index_type Index encoding: "auto" (default), "z3", "z7", or "zorder" #' -#' @return Index string +#' @return A character vector of index strings. #' #' @family hierarchical index #' @keywords internal #' @export -#' @examples -#' idx <- hexify_lonlat_to_index(16.37, 48.21, resolution = 5, aperture = 3) +#' @examples +#' idx <- hexify_lonlat_to_index(16.37, 48.21, resolution = 5, aperture = 3) +#' idx7 <- hexify_lonlat_to_index(16.37, 48.21, +#' resolution = 4, aperture = 7 +#' ) hexify_lonlat_to_index <- function(lon, lat, resolution, aperture = 3L, index_type = c("auto", "z3", "z7", "zorder")) { index_type <- match.arg(index_type) @@ -99,13 +121,15 @@ hexify_lonlat_to_index <- function(lon, lat, resolution, aperture = 3L, #' Convert index string to longitude/latitude #' -#' Returns the cell center coordinates for a given index. +#' Returns the geographic coordinates of an indexed cell's center. This is a +#' cell-level inverse of [hexify_lonlat_to_index()]: the returned point is the +#' center, not necessarily the original input point. #' #' @param index Index string #' @param aperture Aperture (3, 4, or 7) #' @param index_type Index encoding. Default "auto" infers from aperture. #' -#' @return Named numeric vector with lon and lat in degrees +#' @return A named numeric vector with `lon` and `lat` in degrees. #' #' @family hierarchical index #' @keywords internal @@ -292,13 +316,16 @@ hexify_cell_id_to_quad_ij <- function(cell_id, resolution, aperture) { #' Get canonical form of Z7 index #' -#' Returns the stable form of a Z7 index after decode/encode. Z7 indices are -#' bijective, so valid indices are already canonical. +#' Decodes and re-encodes a Z7 index until it reaches a stable form. Current Z7 +#' indices are bijective, so every valid index is already canonical and this +#' function normally returns its input unchanged. It remains available for +#' validating or normalizing indices created by older hexify versions. #' -#' @param index Z7 index string -#' @param max_iterations Maximum decode/encode iterations (default 128) +#' @param index A length-one Z7 index string. +#' @param max_iterations Maximum number of decode/encode iterations. This is a +#' safety bound for legacy indices; the default is 128. #' -#' @return Stable canonical form of the index +#' @return A length-one character string containing the stable index. #' #' @family hierarchical index #' @keywords internal @@ -306,6 +333,14 @@ hexify_cell_id_to_quad_ij <- function(cell_id, resolution, aperture) { #' @examples #' # Valid Z7 indices are stable #' hexify_z7_canonical("110001") +#' +#' cell <- hexify_index_to_cell("110001", aperture = 7) +#' identical( +#' hexify_cell_to_index(cell$face, cell$i, cell$j, +#' cell$resolution, aperture = 7 +#' ), +#' "110001" +#' ) hexify_z7_canonical <- function(index, max_iterations = 128L) { cpp_z7_canonical_form(as.character(index), as.integer(max_iterations)) } diff --git a/man/hexify_cell_to_index.Rd b/man/hexify_cell_to_index.Rd index 1066407..092d5fc 100644 --- a/man/hexify_cell_to_index.Rd +++ b/man/hexify_cell_to_index.Rd @@ -27,22 +27,33 @@ hexify_cell_to_index( \item{index_type}{Index encoding: "auto" (default), "z3", "z7", or "zorder"} } \value{ -Index string (e.g., "051223") +A character vector of index strings. } \description{ -Converts DGGRID cell coordinates (face, i, j) to a hierarchical index string. -The index type is automatically selected based on aperture unless specified. +Converts cell coordinates (\code{face}, \code{i}, \code{j}) to a hierarchical index string. +The index type is selected from \code{aperture} when \code{index_type = "auto"}. } \details{ Default index types by aperture: \itemize{ \item Aperture 3: Z3 (optimized digit selection) \item Aperture 4: Z-order (Morton curve) -\item Aperture 7: Z7 (hierarchical with Class III handling) +\item Aperture 7: Z7 (one base-7 child digit per resolution) } + +A Z7 index has the form \code{BBd1...dr}, where \code{BB} is the two-digit base +cell (00--11), \code{r} is the resolution, and every child digit is in 0--6. +hexify's Z7 encoding is bijective: decoding and re-encoding a valid index +returns the same string. It follows DGGRID's Z7 layout for ordinary cells, +but retains distinct indices in pentagon regions where DGGRID's encoder can +map different cells to the same string. } \examples{ idx <- hexify_cell_to_index(5, 10, 15, resolution = 3, aperture = 3) + +# Aperture-7 indices contain a two-digit base cell and one digit per level +z7 <- hexify_cell_to_index(5, 1, 1, resolution = 2, aperture = 7) +nchar(z7) == 4L } \seealso{ Other hierarchical index: diff --git a/man/hexify_index_to_cell.Rd b/man/hexify_index_to_cell.Rd index c591223..6f9edd1 100644 --- a/man/hexify_index_to_cell.Rd +++ b/man/hexify_index_to_cell.Rd @@ -18,13 +18,20 @@ hexify_index_to_cell( \item{index_type}{Index encoding used. Default "auto" infers from aperture.} } \value{ -List with face, i, j, and resolution +A list with \code{face}, \code{i}, \code{j}, and \code{resolution}. } \description{ -Decodes a hierarchical index string back to cell coordinates. +Decodes a hierarchical index string back to its cell coordinates and +resolution. For Z7, valid indices round-trip exactly through +\code{\link[=hexify_cell_to_index]{hexify_cell_to_index()}}. } \examples{ cell <- hexify_index_to_cell("0012012", aperture = 3) + +z7_cell <- hexify_index_to_cell("110001", aperture = 7) +hexify_cell_to_index(z7_cell$face, z7_cell$i, z7_cell$j, + z7_cell$resolution, aperture = 7 +) } \seealso{ Other hierarchical index: diff --git a/man/hexify_index_to_lonlat.Rd b/man/hexify_index_to_lonlat.Rd index 065499f..87bffdf 100644 --- a/man/hexify_index_to_lonlat.Rd +++ b/man/hexify_index_to_lonlat.Rd @@ -18,10 +18,12 @@ hexify_index_to_lonlat( \item{index_type}{Index encoding. Default "auto" infers from aperture.} } \value{ -Named numeric vector with lon and lat in degrees +A named numeric vector with \code{lon} and \code{lat} in degrees. } \description{ -Returns the cell center coordinates for a given index. +Returns the geographic coordinates of an indexed cell's center. This is a +cell-level inverse of \code{\link[=hexify_lonlat_to_index]{hexify_lonlat_to_index()}}: the returned point is the +center, not necessarily the original input point. } \examples{ coords <- hexify_index_to_lonlat("0012012", aperture = 3) diff --git a/man/hexify_lonlat_to_index.Rd b/man/hexify_lonlat_to_index.Rd index 297e967..4aea58a 100644 --- a/man/hexify_lonlat_to_index.Rd +++ b/man/hexify_lonlat_to_index.Rd @@ -24,13 +24,17 @@ hexify_lonlat_to_index( \item{index_type}{Index encoding: "auto" (default), "z3", "z7", or "zorder"} } \value{ -Index string +A character vector of index strings. } \description{ -Main entry point for geocoding points to grid cells. +Projects geographic coordinates to grid cells and returns their hierarchical +index strings. Inputs are vectorized over \code{lon} and \code{lat}. } \examples{ idx <- hexify_lonlat_to_index(16.37, 48.21, resolution = 5, aperture = 3) +idx7 <- hexify_lonlat_to_index(16.37, 48.21, + resolution = 4, aperture = 7 +) } \seealso{ Other hierarchical index: diff --git a/man/hexify_z7_canonical.Rd b/man/hexify_z7_canonical.Rd index 58e3f40..48a6c33 100644 --- a/man/hexify_z7_canonical.Rd +++ b/man/hexify_z7_canonical.Rd @@ -7,20 +7,31 @@ hexify_z7_canonical(index, max_iterations = 128L) } \arguments{ -\item{index}{Z7 index string} +\item{index}{A length-one Z7 index string.} -\item{max_iterations}{Maximum decode/encode iterations (default 128)} +\item{max_iterations}{Maximum number of decode/encode iterations. This is a +safety bound for legacy indices; the default is 128.} } \value{ -Stable canonical form of the index +A length-one character string containing the stable index. } \description{ -Returns the stable form of a Z7 index after decode/encode. Z7 indices are -bijective, so valid indices are already canonical. +Decodes and re-encodes a Z7 index until it reaches a stable form. Current Z7 +indices are bijective, so every valid index is already canonical and this +function normally returns its input unchanged. It remains available for +validating or normalizing indices created by older hexify versions. } \examples{ # Valid Z7 indices are stable hexify_z7_canonical("110001") + +cell <- hexify_index_to_cell("110001", aperture = 7) +identical( + hexify_cell_to_index(cell$face, cell$i, cell$j, + cell$resolution, aperture = 7 + ), + "110001" +) } \seealso{ Other hierarchical index: diff --git a/vignettes/theory.Rmd b/vignettes/theory.Rmd index b725c94..ec148cb 100644 --- a/vignettes/theory.Rmd +++ b/vignettes/theory.Rmd @@ -595,7 +595,18 @@ where **BB** is the base cell (00--11) and each digit $D_k \in \{0, 1, \ldots, 6 | 5 | IK_AXES | IK-axis direction | | 6 | IJ_AXES | IJ-axis direction | -Pentagon cells (at icosahedron vertices) have only 5 children instead of 7. Base cells 0--5 skip digit 2 (J_AXES); cells 6--11 skip digit 5 (IK_AXES). +The first two characters always identify the base cell, so the resolution is +`nchar(index) - 2`. Dropping the final digit gives the parent; appending digits +0--6 enumerates the seven positions in the next refinement level. + +hexify uses a **bijective Z7 variant**. DGGRID's base-cell reassignment and +pentagon digit-skip rules can make its encoder non-injective near pentagons: +distinct cells may receive the same string. hexify keeps the geographic quad +fixed in those regions, giving every cell a distinct index and guaranteeing +`index -> cell -> index` round-trips. Away from those pentagon-region cases, +the strings follow the DGGRID Z7 layout. This distinction matters when +exchanging raw Z7 strings with DGGRID; geographic coordinates and cell geometry +remain the safest interoperability layer. ```{r z7-example} # Z7 index encoding for aperture 7 @@ -612,6 +623,14 @@ parent_info <- hexify_index_to_cell(parent_idx, 7, "z7") cat(sprintf(" Parent index: %s (face %d, i=%d, j=%d)\n", parent_idx, parent_info$face, as.integer(parent_info$i), as.integer(parent_info$j))) + +# A valid Z7 index decodes and re-encodes without changing +decoded <- hexify_index_to_cell(idx, 7, "z7") +stopifnot(identical( + hexify_cell_to_index(decoded$face, decoded$i, decoded$j, + decoded$resolution, 7, "z7"), + idx +)) ``` #### Z3 Index (Aperture 3) @@ -662,7 +681,11 @@ cat(sprintf("Aperture 4: Cell %d -> Z-order index: %s\n", cell, idx)) | **Parent operation** | Drop last digit | Drop last pair | Drop last digit(s) | | **Index length** (res $r$) | $2 + r$ | $2 + 2\lceil r/2\rceil$ | $2 + r$ or $2 + 2r$ | -All three encodings are bijective: each $(quad, i, j)$ triple maps to exactly one index string, and vice versa. hexify stores indices as character strings to support arbitrary precision and avoid integer overflow at high resolutions. +All three hexify encodings are bijective: each valid $(quad, i, j)$ cell maps +to exactly one index string, and vice versa. hexify stores indices as character +strings to support arbitrary precision and avoid integer overflow at high +resolutions. As noted above, hexify's bijective Z7 strings intentionally differ +from DGGRID in the pentagon regions where DGGRID's raw Z7 encoding collides. ### SEQNUM: The Flat Cell ID From f0d694c449b36bac4288d3412b578fc8871cdd78 Mon Sep 17 00:00:00 2001 From: Gilles Colling Date: Thu, 13 Aug 2026 15:53:39 +0200 Subject: [PATCH 3/4] fix(constants): correct kAp7RotDeg formula and value, dedupe literal kAp7RotDeg was mislabeled as atan(sqrt(3/7)) (~33.2 deg) and its numeric literal drifted from the true value starting at the 12th significant digit (~233,577 ULPs), introduced in the initial commit and never caught. The correct closed form is atan(sqrt(3)/5), matching the already-correct kCos19/kSin19 constants a few lines below it in grid_math.h. Cross-checked against DGGRID's M_AP7_ROT_DEGS in DgConstants.h, which matches to all digits given (expressed there as asin(sqrt(3/28)), an equivalent closed form for the same angle). coordinate_transforms.cpp carried its own duplicate literal of the wrong value; it now derives kAp7RotRad from constants.h's kAp7RotDeg instead. Reported by Christian Carey. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Cmiqcjs9Ev27KLkdgHmPkV --- src/constants.h | 359 ++--- src/coordinate_transforms.cpp | 2698 ++++++++++++++++----------------- src/grid_math.h | 858 +++++------ 3 files changed, 1958 insertions(+), 1957 deletions(-) diff --git a/src/constants.h b/src/constants.h index eb4f8f6..fb4e3af 100644 --- a/src/constants.h +++ b/src/constants.h @@ -1,179 +1,180 @@ -// constants.h - Shared mathematical constants for hexify -// -// All constants computed to full IEEE 754 double precision (15-17 significant digits). -// Values verified against Wolfram Alpha / mpfr where applicable. -// -// Copyright (c) 2024 hexify authors. MIT License. - -#ifndef HEXIFY_CONSTANTS_H -#define HEXIFY_CONSTANTS_H - -namespace hexify { - -// ============================================================================= -// Fundamental Mathematical Constants -// ============================================================================= - -// Pi and multiples (full double precision) -constexpr double kPi = 3.141592653589793238462643383279502884; -constexpr double kTwoPi = 6.283185307179586476925286766559005768; -constexpr double kPiOver2 = 1.570796326794896619231321691639751442; -constexpr double kPiOver3 = 1.047197551196597746154214461093167628; -constexpr double kPiOver6 = 0.523598775598298873077107230546583814; - -// ============================================================================= -// Square Roots -// ============================================================================= - -constexpr double kSqrt3 = 1.732050807568877293527446341505872367; -constexpr double kSqrt7 = 2.645751311064590590501615753639260426; -constexpr double kSqrt21 = 4.582575694955840006588047193728008489; // sqrt(3 * 7) - -// ============================================================================= -// Trigonometric Values -// ============================================================================= - -constexpr double kSin60 = 0.866025403784438646763723170752936183; // sqrt(3)/2 -constexpr double kCos60 = 0.5; -constexpr double kSin30 = 0.5; -constexpr double kCos30 = 0.866025403784438646763723170752936183; // sqrt(3)/2 - -// ============================================================================= -// Degree/Radian Conversion -// ============================================================================= - -constexpr double kDegToRad = 0.017453292519943295769236907684886127; // pi/180 -constexpr double kRadToDeg = 57.29577951308232087679815481410517033; // 180/pi - -// ============================================================================= -// ISEA Projection Constants -// ============================================================================= - -// Aperture 7 rotation angle: arctan(sqrt(3/7)) in degrees -// Exact: atan(sqrt(3/7)) = 19.10660535003926...° -constexpr double kAp7RotDeg = 19.10660535003926406149339781619697490; - -// ============================================================================= -// Snyder Projection Sector Angles -// ============================================================================= - -// Triangle sector boundaries (radians) - used for azimuth reduction -constexpr double k2PiOver3 = 2.094395102393195492308428922186335256; // 120° = 2π/3 -constexpr double k4PiOver3 = 4.188790204786390984616857844372670512; // 240° = 4π/3 - -// ============================================================================= -// Snyder ISEA Projection Constants (from Snyder 1992) -// ============================================================================= -// Reference: Snyder, J.P. (1992). "An Equal-Area Map Projection for Polyhedral Globes" -// Cartographica 29(1): 10-21. - -// R1: Scale factor for equal-area property (Snyder notation: R') -constexpr double kSnyderR1 = 0.9103832815; -constexpr double kSnyderR1Squared = kSnyderR1 * kSnyderR1; - -// SNYDER_EL_ANGLE: θ - spherical angle from face center to edge midpoint (37.377...°) -// This is the "el" angle in Snyder's notation -constexpr double kSnyderElAngleDeg = 37.37736814; -constexpr double kSnyderElAngle = kSnyderElAngleDeg * kDegToRad; - -// SNYDER_G_ANGLE: G - angle at icosahedron vertices (36° exactly) -constexpr double kSnyderGAngleDeg = 36.0; -constexpr double kSnyderGAngle = kSnyderGAngleDeg * kDegToRad; - -// Face-plane origin offsets (for normalizing projected coordinates) -constexpr double kSnyderOriginXOff = 0.6022955029; -constexpr double kSnyderOriginYOff = 0.3477354707; -constexpr double kSnyderIcosaEdge = 2.0 * kSnyderOriginXOff; - -// ============================================================================= -// PLANE Coordinate Layout Table (Icosahedron Unfolding) -// ============================================================================= -// Each triangle has a rotation (in 60° increments) and an offset position -// in the unfolded PLANE coordinate system. Standard ISEA icosahedron layout. - -// Structure to hold triangle transformation parameters -struct PlaneTriLayout { - int rot60; // Rotation in 60° increments (0, 3, etc.) - double offset_x; // X offset in PLANE coordinates - double offset_y; // Y offset in PLANE coordinates -}; - -// M_SIN60 = sqrt(3)/2 ≈ 0.866025... -// The layout creates a 5.5 × ~1.73 unit rectangle containing all 20 triangles - -constexpr PlaneTriLayout kPlaneLayout[20] = { - // Upper row (faces 0-4): rot60=0, y = 2*sin60 - {0, 0.0, 2.0 * kSin60}, // face 0 - {0, 1.0, 2.0 * kSin60}, // face 1 - {0, 2.0, 2.0 * kSin60}, // face 2 - {0, 3.0, 2.0 * kSin60}, // face 3 - {0, 4.0, 2.0 * kSin60}, // face 4 - // Upper row continued (faces 5-9): rot60=3, y = 2*sin60 - {3, 1.0, 2.0 * kSin60}, // face 5 - {3, 2.0, 2.0 * kSin60}, // face 6 - {3, 3.0, 2.0 * kSin60}, // face 7 - {3, 4.0, 2.0 * kSin60}, // face 8 - {3, 5.0, 2.0 * kSin60}, // face 9 - // Lower row (faces 10-14): rot60=0, y = sin60 - {0, 0.5, kSin60}, // face 10 - {0, 1.5, kSin60}, // face 11 - {0, 2.5, kSin60}, // face 12 - {0, 3.5, kSin60}, // face 13 - {0, 4.5, kSin60}, // face 14 - // Lower row continued (faces 15-19): rot60=3, y = sin60 - {3, 1.5, kSin60}, // face 15 - {3, 2.5, kSin60}, // face 16 - {3, 3.5, kSin60}, // face 17 - {3, 4.5, kSin60}, // face 18 - {3, 5.5, kSin60} // face 19 -}; - -// ============================================================================= -// Grid Bounds -// ============================================================================= -// Mirrors R/constants.R's MIN_RESOLUTION/MAX_RESOLUTION. Resolutions outside -// this range are rejected before they can reach shift-overflow or -// scale-to-infinity arithmetic in the grid-dimension calculations. - -constexpr int kMinResolution = 0; -constexpr int kMaxResolution = 30; - -// Valid range for a quad index (12 quads: 0 = north pole, 1-10 = equatorial -// belt, 11 = south pole) -constexpr int kMinQuad = 0; -constexpr int kMaxQuad = 11; - -// Valid range for an icosahedron triangle face index -constexpr int kMinFace = 0; -constexpr int kMaxFace = 19; - -// ============================================================================= -// Numerical Precision Constants -// ============================================================================= - -// Minimum denominator value to prevent division by zero in floating-point math -constexpr double kMinDenom = 1e-18; - -// Epsilon for branching decisions (very small values treated as zero) -constexpr double kEpsBranch = 1e-15; - -// ============================================================================= -// Inline Utility Functions -// ============================================================================= - -/** - * Returns a non-zero denominator suitable for division. - * If abs(x) < kMinDenom, returns kMinDenom with the original sign (or positive if x=0). - * This prevents division-by-zero while preserving the sign of near-zero values. - */ -inline double safe_denom(double x) noexcept { - if (x >= 0.0) { - return (x < kMinDenom) ? kMinDenom : x; - } else { - return (x > -kMinDenom) ? -kMinDenom : x; - } -} - -} // namespace hexify - -#endif // HEXIFY_CONSTANTS_H +// constants.h - Shared mathematical constants for hexify +// +// All constants computed to full IEEE 754 double precision (15-17 significant digits). +// Values verified against Wolfram Alpha / mpfr where applicable. +// +// Copyright (c) 2024 hexify authors. MIT License. + +#ifndef HEXIFY_CONSTANTS_H +#define HEXIFY_CONSTANTS_H + +namespace hexify { + +// ============================================================================= +// Fundamental Mathematical Constants +// ============================================================================= + +// Pi and multiples (full double precision) +constexpr double kPi = 3.141592653589793238462643383279502884; +constexpr double kTwoPi = 6.283185307179586476925286766559005768; +constexpr double kPiOver2 = 1.570796326794896619231321691639751442; +constexpr double kPiOver3 = 1.047197551196597746154214461093167628; +constexpr double kPiOver6 = 0.523598775598298873077107230546583814; + +// ============================================================================= +// Square Roots +// ============================================================================= + +constexpr double kSqrt3 = 1.732050807568877293527446341505872367; +constexpr double kSqrt7 = 2.645751311064590590501615753639260426; +constexpr double kSqrt21 = 4.582575694955840006588047193728008489; // sqrt(3 * 7) + +// ============================================================================= +// Trigonometric Values +// ============================================================================= + +constexpr double kSin60 = 0.866025403784438646763723170752936183; // sqrt(3)/2 +constexpr double kCos60 = 0.5; +constexpr double kSin30 = 0.5; +constexpr double kCos30 = 0.866025403784438646763723170752936183; // sqrt(3)/2 + +// ============================================================================= +// Degree/Radian Conversion +// ============================================================================= + +constexpr double kDegToRad = 0.017453292519943295769236907684886127; // pi/180 +constexpr double kRadToDeg = 57.29577951308232087679815481410517033; // 180/pi + +// ============================================================================= +// ISEA Projection Constants +// ============================================================================= + +// Aperture 7 rotation angle: arctan(sqrt(3)/5) in degrees +// Exact: atan(sqrt(3)/5) = 19.10660535086909...° +// Cross-checked against DGGRID's M_AP7_ROT_DEGS (src/lib/dglib/include/dglib/DgConstants.h) +constexpr double kAp7RotDeg = 19.106605350869094394517474740130082234976075229; + +// ============================================================================= +// Snyder Projection Sector Angles +// ============================================================================= + +// Triangle sector boundaries (radians) - used for azimuth reduction +constexpr double k2PiOver3 = 2.094395102393195492308428922186335256; // 120° = 2π/3 +constexpr double k4PiOver3 = 4.188790204786390984616857844372670512; // 240° = 4π/3 + +// ============================================================================= +// Snyder ISEA Projection Constants (from Snyder 1992) +// ============================================================================= +// Reference: Snyder, J.P. (1992). "An Equal-Area Map Projection for Polyhedral Globes" +// Cartographica 29(1): 10-21. + +// R1: Scale factor for equal-area property (Snyder notation: R') +constexpr double kSnyderR1 = 0.9103832815; +constexpr double kSnyderR1Squared = kSnyderR1 * kSnyderR1; + +// SNYDER_EL_ANGLE: θ - spherical angle from face center to edge midpoint (37.377...°) +// This is the "el" angle in Snyder's notation +constexpr double kSnyderElAngleDeg = 37.37736814; +constexpr double kSnyderElAngle = kSnyderElAngleDeg * kDegToRad; + +// SNYDER_G_ANGLE: G - angle at icosahedron vertices (36° exactly) +constexpr double kSnyderGAngleDeg = 36.0; +constexpr double kSnyderGAngle = kSnyderGAngleDeg * kDegToRad; + +// Face-plane origin offsets (for normalizing projected coordinates) +constexpr double kSnyderOriginXOff = 0.6022955029; +constexpr double kSnyderOriginYOff = 0.3477354707; +constexpr double kSnyderIcosaEdge = 2.0 * kSnyderOriginXOff; + +// ============================================================================= +// PLANE Coordinate Layout Table (Icosahedron Unfolding) +// ============================================================================= +// Each triangle has a rotation (in 60° increments) and an offset position +// in the unfolded PLANE coordinate system. Standard ISEA icosahedron layout. + +// Structure to hold triangle transformation parameters +struct PlaneTriLayout { + int rot60; // Rotation in 60° increments (0, 3, etc.) + double offset_x; // X offset in PLANE coordinates + double offset_y; // Y offset in PLANE coordinates +}; + +// M_SIN60 = sqrt(3)/2 ≈ 0.866025... +// The layout creates a 5.5 × ~1.73 unit rectangle containing all 20 triangles + +constexpr PlaneTriLayout kPlaneLayout[20] = { + // Upper row (faces 0-4): rot60=0, y = 2*sin60 + {0, 0.0, 2.0 * kSin60}, // face 0 + {0, 1.0, 2.0 * kSin60}, // face 1 + {0, 2.0, 2.0 * kSin60}, // face 2 + {0, 3.0, 2.0 * kSin60}, // face 3 + {0, 4.0, 2.0 * kSin60}, // face 4 + // Upper row continued (faces 5-9): rot60=3, y = 2*sin60 + {3, 1.0, 2.0 * kSin60}, // face 5 + {3, 2.0, 2.0 * kSin60}, // face 6 + {3, 3.0, 2.0 * kSin60}, // face 7 + {3, 4.0, 2.0 * kSin60}, // face 8 + {3, 5.0, 2.0 * kSin60}, // face 9 + // Lower row (faces 10-14): rot60=0, y = sin60 + {0, 0.5, kSin60}, // face 10 + {0, 1.5, kSin60}, // face 11 + {0, 2.5, kSin60}, // face 12 + {0, 3.5, kSin60}, // face 13 + {0, 4.5, kSin60}, // face 14 + // Lower row continued (faces 15-19): rot60=3, y = sin60 + {3, 1.5, kSin60}, // face 15 + {3, 2.5, kSin60}, // face 16 + {3, 3.5, kSin60}, // face 17 + {3, 4.5, kSin60}, // face 18 + {3, 5.5, kSin60} // face 19 +}; + +// ============================================================================= +// Grid Bounds +// ============================================================================= +// Mirrors R/constants.R's MIN_RESOLUTION/MAX_RESOLUTION. Resolutions outside +// this range are rejected before they can reach shift-overflow or +// scale-to-infinity arithmetic in the grid-dimension calculations. + +constexpr int kMinResolution = 0; +constexpr int kMaxResolution = 30; + +// Valid range for a quad index (12 quads: 0 = north pole, 1-10 = equatorial +// belt, 11 = south pole) +constexpr int kMinQuad = 0; +constexpr int kMaxQuad = 11; + +// Valid range for an icosahedron triangle face index +constexpr int kMinFace = 0; +constexpr int kMaxFace = 19; + +// ============================================================================= +// Numerical Precision Constants +// ============================================================================= + +// Minimum denominator value to prevent division by zero in floating-point math +constexpr double kMinDenom = 1e-18; + +// Epsilon for branching decisions (very small values treated as zero) +constexpr double kEpsBranch = 1e-15; + +// ============================================================================= +// Inline Utility Functions +// ============================================================================= + +/** + * Returns a non-zero denominator suitable for division. + * If abs(x) < kMinDenom, returns kMinDenom with the original sign (or positive if x=0). + * This prevents division-by-zero while preserving the sign of near-zero values. + */ +inline double safe_denom(double x) noexcept { + if (x >= 0.0) { + return (x < kMinDenom) ? kMinDenom : x; + } else { + return (x > -kMinDenom) ? -kMinDenom : x; + } +} + +} // namespace hexify + +#endif // HEXIFY_CONSTANTS_H diff --git a/src/coordinate_transforms.cpp b/src/coordinate_transforms.cpp index 66f9627..0c75adb 100644 --- a/src/coordinate_transforms.cpp +++ b/src/coordinate_transforms.cpp @@ -1,1349 +1,1349 @@ -// coordinate_transforms.cpp - Convert between ISEA DGGS coordinate systems -// -// ============================================================================ -// COORDINATE TRANSFORMATION FLOW -// ============================================================================ -// -// This file implements the coordinate transformations between three systems: -// -// +---------------------+ +---------------+ +-----------+ -// | Icosa Triangle | --> | Quad XY | --> | Quad IJ | -// | (icosa_triangle_ | | (quad, | | (quad, | -// | face, _x, _y) | | quad_x, | | i, j) | -// +---------------------+ | quad_y) | +-----------+ -// | +---------------+ | -// v | v -// Icosahedral face Quad continuous Quad integer -// coordinates coordinates cell indices -// -// Icosa Triangle: Output from Snyder forward projection -// - icosa_triangle_face: Triangle index (0-19) -// - icosa_triangle_x, icosa_triangle_y: Normalized coords within triangle [0,1] -// -// Quad XY: Quad with continuous (double) coordinates -// - quad: Quad index (0-11, where 0=North pole, 11=South pole) -// - quad_x, quad_y: Continuous position within quad -// -// Quad IJ: Quad with integer cell indices (used for cell ID computation) -// - quad: Same as Quad XY -// - i, j: Integer cell coordinates (resolution-dependent) -// -// ============================================================================ -// ICOSAHEDRON GEOMETRY -// ============================================================================ -// -// The icosahedron has 20 triangular faces grouped into 12 quads: -// -// Quad 0 (North Pole) -// /\ -// / \ -// +----+----+----+----+----+ -// | Q1 | Q2 | Q3 | Q4 | Q5 | <- Upper hemisphere (quads 1-5) -// +----+----+----+----+----+ -// | Q6 | Q7 | Q8 | Q9 |Q10 | <- Lower hemisphere (quads 6-10) -// +----+----+----+----+----+ -// \ / -// \/ -// Quad 11 (South Pole) -// -// Each non-polar quad contains 2 triangles forming a rhombus. -// Triangles 0-4 and 5-9 map to quads 1-5 -// Triangles 10-14 and 15-19 map to quads 6-10 -// -// ============================================================================ -// QUANTIZATION CLASSES -// ============================================================================ -// -// Different apertures use different quantization schemes: -// -// Aperture 3: -// - Even resolutions: Class I (aligned hexagons) -// - Odd resolutions: Class II (rotated hexagons) -// -// Aperture 4: -// - All resolutions: Class I (aligned hexagons) -// -// Aperture 7: -// - Even resolutions: Class III-I -// - Odd resolutions: Class III-II -// -// Mathematical foundation from Sahr et al. publications on ISEA grids. -// -// Copyright (c) 2024 hexify authors. MIT License. - -#include "coordinate_transforms.h" -#include "cube_coordinates.h" -#include "ijk_coordinates.h" -#include "index_z7.h" -#include "constants.h" -#include -#include -#include - -namespace hexify { - -namespace { - -// ============================================================================ -// Triangle to Quad Mapping -// ============================================================================ -// -// Icosahedron face layout (20 triangles -> 12 quads): -// -// North Pole (Quad 0) at top, South Pole (Quad 11) at bottom. -// Quads 1-5: upper hemisphere, Quads 6-10: lower hemisphere. -// Each quad contains 2 triangles forming a rhombus shape. -// -// Each non-polar quad contains 2 triangles. The mapping specifies: -// - Which quad a triangle belongs to -// - Rotation and translation to align triangle coords with quad coords - -struct TriangleMapping { - int quad; // Target quad (1-10 for regular quads) - int sub_triangle; // 0 = primary, 1 = secondary (rotated/translated) - double offset_x; // X offset after rotation - double offset_y; // Y offset after rotation - int rotations; // Number of 60° clockwise rotations -}; - -// Mapping table derived from ISEA icosahedron geometry -// Triangle indices 0-19 map to quads 1-10 (polar quads 0,11 handled separately) -const TriangleMapping kTriangleMap[20] = { - // Upper cap triangles (0-4) -> quads 1-5, primary position - {1, 0, 0.0, 0.0, 1}, - {2, 0, 0.0, 0.0, 1}, - {3, 0, 0.0, 0.0, 1}, - {4, 0, 0.0, 0.0, 1}, - {5, 0, 0.0, 0.0, 1}, - // Upper-middle triangles (5-9) -> quads 1-5, secondary position - {1, 1, -0.5, -kSin60, 4}, - {2, 1, -0.5, -kSin60, 4}, - {3, 1, -0.5, -kSin60, 4}, - {4, 1, -0.5, -kSin60, 4}, - {5, 1, -0.5, -kSin60, 4}, - // Lower-middle triangles (10-14) -> quads 6-10, primary position - {6, 0, 0.0, 0.0, 1}, - {7, 0, 0.0, 0.0, 1}, - {8, 0, 0.0, 0.0, 1}, - {9, 0, 0.0, 0.0, 1}, - {10, 0, 0.0, 0.0, 1}, - // Lower cap triangles (15-19) -> quads 6-10, secondary position - {6, 1, -0.5, -kSin60, 4}, - {7, 1, -0.5, -kSin60, 4}, - {8, 1, -0.5, -kSin60, 4}, - {9, 1, -0.5, -kSin60, 4}, - {10, 1, -0.5, -kSin60, 4}, -}; - -// ============================================================================ -// Rotation Helper -// ============================================================================ - -void rotate_60deg_ccw(double& x, double& y, int n_rotations) { - // Each 60° counter-clockwise rotation: [cos(60) -sin(60); sin(60) cos(60)] - // cos(60°) = 0.5, sin(60°) = sqrt(3)/2 - constexpr double c60 = 0.5; - constexpr double s60 = kSin60; - - n_rotations = ((n_rotations % 6) + 6) % 6; // Normalize to 0-5 - - for (int i = 0; i < n_rotations; ++i) { - double nx = c60 * x - s60 * y; // counter-clockwise: x*cos - y*sin - double ny = s60 * x + c60 * y; // counter-clockwise: x*sin + y*cos - x = nx; - y = ny; - } -} - -// ============================================================================ -// Hex Quantization - Precise hexagonal grid rounding -// ============================================================================ -// This implementation handles all edge cases at hexagon boundaries correctly -// using a decision-tree approach that carefully handles the fractional parts -// of the continuous coordinates. This is more robust than simple cube-coordinate -// rounding at cell boundaries. - -// ============================================================================ -// Boundary Classification for Hex Quantization -// ============================================================================ -// -// The unit cell is divided into 6 regions based on fractional coordinates (frac_i, frac_j). -// Each region determines the (delta_i, delta_j) offset from the base cell (floor_i, floor_j). -// -// The regions form a hexagonal Voronoi partition: -// - Region A: frac_i < 1/3, frac_j < (1+frac_i)/2 -> (0, 0) -// - Region B: frac_i < 1/3, frac_j >= (1+frac_i)/2 -> (0, 1) -// - Region C: 1/3 <= frac_i < 1/2 -> complex boundary (see below) -// - Region D: 1/2 <= frac_i < 2/3 -> complex boundary (see below) -// - Region E: frac_i >= 2/3, frac_j < frac_i/2 -> (1, 0) -// - Region F: frac_i >= 2/3, frac_j >= frac_i/2 -> (1, 1) -// -// For regions C and D, the i-offset depends on whether frac_j falls in the -// "middle band" between two linear thresholds. - -// Classify which boundary region based on fractional coords -// Returns: 0=A, 1=B, 2=C_lower, 3=C_upper, 4=C_mid, 5=D_lower, 6=D_upper, 7=D_mid, 8=E, 9=F -inline int classify_hex_boundary(double frac_i, double frac_j) { - if (frac_i < 1.0/3.0) { - return (frac_j < (1.0 + frac_i) / 2.0) ? 0 : 1; // A or B - } - if (frac_i < 0.5) { - // Region C: thresholds at (1-frac_i) and (2*frac_i) - double lower_threshold = 1.0 - frac_i; - double upper_threshold = 2.0 * frac_i; - if (frac_j < lower_threshold) return 2; // C_lower: j=floor_j - if (frac_j >= upper_threshold) return 3; // C_upper: j=floor_j+1 - return 4; // C_mid: i=floor_i+1 - } - if (frac_i < 2.0/3.0) { - // Region D: thresholds at (2*frac_i-1) and (1-frac_i) - double lower_threshold = 2.0 * frac_i - 1.0; - double upper_threshold = 1.0 - frac_i; - if (frac_j <= lower_threshold) return 5; // D_lower: j=floor_j, i=floor_i+1 - if (frac_j >= upper_threshold) return 6; // D_upper: j=floor_j+1, i=floor_i+1 - return 7; // D_mid: i=floor_i - } - return (frac_j < frac_i / 2.0) ? 8 : 9; // E or F -} - -// Lookup table: boundary_region -> (delta_i, delta_j) offset -// Indexed by classify_hex_boundary() return value -static const int kBoundaryOffset[10][2] = { - {0, 0}, // 0: Region A - {0, 1}, // 1: Region B - {0, 0}, // 2: Region C_lower (j=floor_j) - {0, 1}, // 3: Region C_upper (j=floor_j+1) - {1, 0}, // 4: Region C_mid - special: j depends on frac_j < (1-frac_i) - {1, 0}, // 5: Region D_lower (j=floor_j) - {1, 1}, // 6: Region D_upper (j=floor_j+1) - {0, 0}, // 7: Region D_mid - special: j depends on frac_j < (1-frac_i) - {1, 0}, // 8: Region E - {1, 1}, // 9: Region F -}; - -// Fold i-coordinate across x-axis when x was negative -inline long long fold_i_negative_x(long long i, long long j) { - if ((j % 2) == 0) { - long long axis = j / 2; - return i - 2 * (i - axis); - } else { - long long axis = (j + 1) / 2; - return i - (2 * (i - axis) + 1); - } -} - -// Class I (flat-top) quantization -void quantize_class1(double x, double y, long long& out_i, long long& out_j) { - // Guard against NaN/Inf inputs to avoid undefined behavior in integer cast - if (!std::isfinite(x) || !std::isfinite(y)) { - out_i = 0; - out_j = 0; - return; - } - - // Work in positive quadrant - double abs_x = std::fabs(x); - double abs_y = std::fabs(y); - - // Convert to fractional hex indices - double idx_j = abs_y / kSin60; - double idx_i = abs_x + idx_j / 2.0; - - // Integer (floor) and fractional parts - long long floor_i = static_cast(idx_i); - long long floor_j = static_cast(idx_j); - double frac_i = idx_i - floor_i; - double frac_j = idx_j - floor_j; - - // Classify and look up base offset - int region = classify_hex_boundary(frac_i, frac_j); - long long delta_i = kBoundaryOffset[region][0]; - long long delta_j = kBoundaryOffset[region][1]; - - // Handle special cases where j depends on secondary threshold - if (region == 4) { // C_mid - delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; - } else if (region == 7) { // D_mid - delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; - } - - long long i_result = floor_i + delta_i; - long long j_result = floor_j + delta_j; - - // Fold back to original quadrant - if (x < 0.0) { - i_result = fold_i_negative_x(i_result, j_result); - } - if (y < 0.0) { - i_result = i_result - (2 * j_result + 1) / 2; - j_result = -j_result; - } - - out_i = i_result; - out_j = j_result; -} - -// Class I inverse: (i,j) to (x,y) -void inv_quantize_class1(long long i, long long j, double& x, double& y) { - cube_to_cartesian(static_cast(i), static_cast(j), x, y, kSin60); -} - -// Class II (pointy-top / 30° rotated) quantization -void quantize_class2(double x, double y, long long& out_i, long long& out_j) { - constexpr double angle = -kPi / 6.0; // -30° - double c = std::cos(angle); - double s = std::sin(angle); - - // Rotate to surrogate Class I orientation - double rx = x * c - y * s; - double ry = x * s + y * c; - - // Quantize in surrogate - long long sur_i, sur_j; - quantize_class1(rx, ry, sur_i, sur_j); - - // Get surrogate center and rotate back - double sur_x, sur_y; - inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); - - double back_x = sur_x * c + sur_y * s; // Rotate +30° - double back_y = -sur_x * s + sur_y * c; - - // Scale to substrate and re-quantize - quantize_class1(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); -} - - -// ============================================================================ -// Class III Quantization (Aperture 7) -// ============================================================================ -// Class III hexagons are rotated by arctan(sqrt(3/7)) ~= 19.1deg from Class I. -// This creates a grid where only 1/7 of substrate cells are valid. - -// Aperture 7 rotation angle in radians -constexpr double kAp7RotRad = 19.10660535003926406149339781619697490 * kPi / 180.0; - -// Class III-I (even resolutions): Class I surrogate rotated by ~19.1deg -void quantize_class3i(double x, double y, long long& out_i, long long& out_j) { - const double c = std::cos(-kAp7RotRad); - const double s = std::sin(-kAp7RotRad); - - // Rotate to surrogate - double rx = x * c - y * s; - double ry = x * s + y * c; - - // Quantize in Class I surrogate - long long sur_i, sur_j; - quantize_class1(rx, ry, sur_i, sur_j); - - // Get surrogate center and rotate back - double sur_x, sur_y; - inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); - - double back_x = sur_x * c + sur_y * s; - double back_y = -sur_x * s + sur_y * c; - - // Scale to substrate (sqrt(7)x finer) and re-quantize - quantize_class1(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); -} - -// Class III-II (odd resolutions): Class II surrogate rotated by ~19.1deg -// The surrogate is a Class II grid (pointy-top, 30° rotated from Class I). -// We need to: -// 1. Rotate to surrogate frame (-19.1 degrees) -// 2. Quantize in Class II surrogate (which is Class I at -30 degrees) -// 3. Get the Class II surrogate center -// 4. Rotate back to original frame (+19.1 degrees) -// 5. Scale to Class I substrate (sqrt(21)x) and re-quantize -void quantize_class3ii(double x, double y, long long& out_i, long long& out_j) { - const double c_ap7 = std::cos(-kAp7RotRad); - const double s_ap7 = std::sin(-kAp7RotRad); - - // Step 1: Rotate to surrogate frame (-19.1 degrees) - double sur_x = x * c_ap7 - y * s_ap7; - double sur_y = x * s_ap7 + y * c_ap7; - - // Step 2: Quantize in Class II surrogate - // Class II = Class I rotated by -30 degrees - constexpr double c_30 = 0.866025403784438646763723170752936183; // cos(-30°) - constexpr double s_30 = -0.5; // sin(-30°) - - // Rotate to Class I orientation within the surrogate - double c1_x = sur_x * c_30 - sur_y * s_30; - double c1_y = sur_x * s_30 + sur_y * c_30; - - // Quantize in Class I - long long sur1_i, sur1_j; - quantize_class1(c1_x, c1_y, sur1_i, sur1_j); - - // Get Class I center - double sur1_cen_x, sur1_cen_y; - inv_quantize_class1(sur1_i, sur1_j, sur1_cen_x, sur1_cen_y); - - // Rotate back to Class II orientation (+30 degrees) - double c2_back_x = sur1_cen_x * c_30 + sur1_cen_y * s_30; - double c2_back_y = -sur1_cen_x * s_30 + sur1_cen_y * c_30; - - // Step 3: Rotate back to original frame (+19.1 degrees) - double back_x = c2_back_x * c_ap7 + c2_back_y * s_ap7; - double back_y = -c2_back_x * s_ap7 + c2_back_y * c_ap7; - - // Step 4: Scale to substrate (sqrt(21)x finer for Class III-II) and re-quantize - quantize_class1(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); -} - -// ============================================================================ -// Quad Edge Adjacency -// ============================================================================ -// -// When a cell falls on the edge of a quad, it may belong to an adjacent quad. -// This table defines the adjacency relationships. - -struct QuadAdjacency { - bool is_upper; // Upper hemisphere quad (1-5) vs lower (6-10) - int up_neighbor; // Quad above (for top edge overflow) - int right_neighbor; // Quad to the right (for right edge overflow) -}; - -const QuadAdjacency kQuadAdjacency[12] = { - {true, 0, 0}, // Quad 0: north pole (unused) - {true, 2, 6}, // Quad 1 - {true, 3, 7}, // Quad 2 - {true, 4, 8}, // Quad 3 - {true, 5, 9}, // Quad 4 - {true, 1, 10}, // Quad 5 - {false, 2, 7}, // Quad 6 - {false, 3, 8}, // Quad 7 - {false, 4, 9}, // Quad 8 - {false, 5, 10}, // Quad 9 - {false, 1, 6}, // Quad 10 - {false, 0, 0}, // Quad 11: south pole (unused) -}; - -} // anonymous namespace - -// ============================================================================ -// Aperture 7: Substrate ↔ True Surrogate Conversion -// ============================================================================ -// These functions convert between substrate (Class I) integer coordinates -// and the "true surrogate" coordinates used internally by DGGRID's Class III -// grids. The surrogate is a Class I (even res) or Class II (odd res) hex grid -// rotated by ~19.1° from the substrate frame. -// -// This is needed for finding neighbors: ±1 offsets in surrogate integer space -// correspond to actual adjacent ap7 cells. - -void substrate_to_surrogate_ap7(long long sub_i, long long sub_j, int resolution, - long long& sur_i, long long& sur_j) { - double x, y; - inv_quantize_class1(sub_i, sub_j, x, y); - - // Substrate Cartesian is sqrt(7)x (even) or sqrt(21)x (odd) larger - // than the surrogate scale. Divide down before rotation + quantization. - bool is_class3i = (resolution % 2 == 0); - double divisor = is_class3i ? kSqrt7 : kSqrt21; - x /= divisor; - y /= divisor; - - const double c = std::cos(-kAp7RotRad); - const double s = std::sin(-kAp7RotRad); - double rx = x * c - y * s; - double ry = x * s + y * c; - - if (is_class3i) { - quantize_class1(rx, ry, sur_i, sur_j); - } else { - constexpr double c_30 = 0.866025403784438646763723170752936183; - constexpr double s_30 = -0.5; - double c1x = rx * c_30 - ry * s_30; - double c1y = rx * s_30 + ry * c_30; - quantize_class1(c1x, c1y, sur_i, sur_j); - } -} - -void surrogate_to_substrate_ap7(long long sur_i, long long sur_j, int resolution, - long long& sub_i, long long& sub_j) { - double sx, sy; - inv_quantize_class1(sur_i, sur_j, sx, sy); - - bool is_class3i = (resolution % 2 == 0); - const double c_ap7 = std::cos(kAp7RotRad); - const double s_ap7 = std::sin(kAp7RotRad); - - double back_x, back_y; - if (is_class3i) { - back_x = sx * c_ap7 - sy * s_ap7; - back_y = sx * s_ap7 + sy * c_ap7; - quantize_class1(back_x * kSqrt7, back_y * kSqrt7, sub_i, sub_j); - } else { - constexpr double c_30 = 0.866025403784438646763723170752936183; - constexpr double s_30 = 0.5; - double c2x = sx * c_30 - sy * s_30; - double c2y = sx * s_30 + sy * c_30; - back_x = c2x * c_ap7 - c2y * s_ap7; - back_y = c2x * s_ap7 + c2y * c_ap7; - quantize_class1(back_x * kSqrt21, back_y * kSqrt21, sub_i, sub_j); - } -} - -// ============================================================================ -// Aperture 7: exact-integer surrogate machinery (matches DGGRID / H3) -// ============================================================================ -// The "surrogate" is hexify's canonical aperture-7 cell coordinate: the exact -// integer IJK of the resolution-r cell. It is obtained by a clean, unrotated -// Class I quantization of the shared quad_xy frame at the Class I substrate -// scale (7^numClassI = sqrt(7)^effectiveRes), DGGRID's edgeTable quad -// canonicalization, and, for odd resolutions, one exact aperture-7 coarsen -// (upAp7r). This replaces the earlier floating-point-rotation surrogate, whose -// re-quantization rounded boundary cells to a neighbour and diverged from the -// exact integer grid. - -namespace { - -// DgIDGGBase::edgeTable_[12]: quads 0/11 are pole placeholders (never occur). -struct DgQuadEdge { int quadNum; bool isType0; int loneVert, up, down, right, left; }; -const DgQuadEdge kDggridEdgeTable[12] = { - {0, true, 0, 0, 0, 0, 0}, - {1, true, 0, 2, 10, 6, 5}, - {2, true, 0, 3, 6, 7, 1}, - {3, true, 0, 4, 7, 8, 2}, - {4, true, 0, 5, 8, 9, 3}, - {5, true, 0, 1, 9, 10, 4}, - {6, false, 11, 2, 10, 7, 1}, - {7, false, 11, 3, 6, 8, 2}, - {8, false, 11, 4, 7, 9, 3}, - {9, false, 11, 5, 8, 10, 4}, - {10, false, 11, 1, 9, 6, 5}, - {11, false, 11, 0, 0, 0, 0}, -}; - -// Reassign an out-of-box Class I (i,j) to the quad that owns it. topEdge = -// 7^numClassI = maxI + 1 = maxJ + 1. Port of DgQ2DDtoIConverter's reassignment. -void dggrid_canonicalize_q2di(long long topEdge, int& quadNum, - long long& i, long long& j) { - const long long maxI = topEdge - 1, maxJ = topEdge - 1; - const long long topEdgeI = topEdge, topEdgeJ = topEdge; - - bool underI = i < 0, underJ = j < 0, overI = i > maxI, overJ = j > maxJ; - int numOver = (int)underI + (int)underJ + (int)overI + (int)overJ; - if (!numOver) return; - - const DgQuadEdge& ec = kDggridEdgeTable[quadNum]; - - if (overI && overJ) { - quadNum = ec.isType0 ? ec.up : ec.right; - i = 0; j = 0; - } else if (numOver > 1) { - return; // multi-underage: unreached for valid cell centres - } else if (underI) { - quadNum = ec.left; - if (ec.isType0) { long long ni = topEdgeJ - j + i, nj = topEdgeJ + i; i = ni; j = nj; } - else { i = topEdgeI + i; } - } else if (underJ) { - quadNum = ec.down; - if (ec.isType0) { j = topEdgeJ + j; } - else { long long ni = topEdgeJ + j, nj = (topEdgeI - i) + j; i = ni; j = nj; } - } else if (overI) { - if (ec.isType0) { quadNum = ec.right; i = i - topEdgeI; } - else if (j == 0) { quadNum = ec.loneVert; i = 0; j = 0; } - else { quadNum = ec.right; long long iOver = i - topEdgeI; long long ni = (topEdgeJ - j) + iOver; i = ni; j = iOver; } - } else if (overJ) { - if (!ec.isType0) { quadNum = ec.up; j = j - topEdgeJ; } - else if (i == 0) { quadNum = ec.loneVert; i = 0; j = 0; } - else { quadNum = ec.up; long long jOver = j - topEdgeJ; long long nj = topEdgeI - i + jOver; i = jOver; j = nj; } - } -} - -} // anonymous namespace - -long long ap7_classI_scale(int resolution) { // 7^numClassI, numClassI = (res+1)/2 - long long s = 1; - for (int k = 0, n = (resolution + 1) / 2; k < n; ++k) s *= 7; - return s; -} - -void ap7_substrate_to_surrogate_ijk(long long sub_i, long long sub_j, int resolution, - long long& sur_i, long long& sur_j) { - if (resolution % 2 == 0) { sur_i = sub_i; sur_j = sub_j; return; } - z7::IVec3D v(sub_i, sub_j, 0); - v.upAp7r(); - z7::IVec2D a(v); - sur_i = a.i(); - sur_j = a.j(); -} - -void ap7_surrogate_to_substrate_ijk(long long sur_i, long long sur_j, int resolution, - long long& sub_i, long long& sub_j) { - if (resolution % 2 == 0) { sub_i = sur_i; sub_j = sur_j; return; } - z7::IVec3D v(sur_i, sur_j, 0); - v.downAp7r(); - z7::IVec2D a(v); - sub_i = a.i(); - sub_j = a.j(); -} - -void quad_xy_to_surrogate_ij_ap7(double quad_x, double quad_y, int resolution, - long long& sur_i, long long& sur_j) { - long long S = ap7_classI_scale(resolution); - long long sub_i, sub_j; - quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), - sub_i, sub_j); - ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, sur_i, sur_j); -} - -void surrogate_ij_to_quad_xy_ap7(long long sur_i, long long sur_j, int resolution, - double& out_quad_x, double& out_quad_y) { - long long S = ap7_classI_scale(resolution); - long long sub_i, sub_j; - ap7_surrogate_to_substrate_ijk(sur_i, sur_j, resolution, sub_i, sub_j); - double cx, cy; - inv_quantize_class1(sub_i, sub_j, cx, cy); - out_quad_x = cx / static_cast(S); - out_quad_y = cy / static_cast(S); -} - -// ============================================================================ -// Public API Implementation -// ============================================================================ - -void icosa_tri_to_quad_xy(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, - int& out_quad, double& out_quad_x, double& out_quad_y) { - if (icosa_triangle_face < 0 || icosa_triangle_face >= 20) { - throw std::runtime_error("icosa_tri_to_quad_xy: icosa_triangle_face must be 0-19"); - } - - const TriangleMapping& mapping = kTriangleMap[icosa_triangle_face]; - - out_quad = mapping.quad; - out_quad_x = icosa_triangle_x; - out_quad_y = icosa_triangle_y; - - // Apply rotation then translation - rotate_60deg_ccw(out_quad_x, out_quad_y, mapping.rotations); - out_quad_x -= mapping.offset_x; - out_quad_y -= mapping.offset_y; -} - -long long get_max_ij(int aperture, int resolution) { - if (resolution <= 0) return 0; - - double factor; - if (aperture == 3) { - factor = std::pow(kSqrt3, resolution); - // Class II (odd res) uses finer substrate - if (resolution % 2 != 0) { - factor *= kSqrt3; - } - } else if (aperture == 4) { - factor = std::pow(2.0, resolution); - } else if (aperture == 7) { - factor = std::pow(std::sqrt(7.0), resolution); - // Class III-I (even res) uses sqrt(7) substrate, Class III-II (odd res) uses sqrt(21) - bool is_class3i = (resolution % 2 == 0); - factor *= is_class3i ? kSqrt7 : kSqrt21; - } else { - return 0; - } - - return static_cast(factor + 1e-9) - 1; -} - -// Handle edge overflow for upper hemisphere quads (1-5) -// Returns true if overflow was handled -inline bool handle_upper_edge(int& quad, long long& i, long long& j, - long long edge_coord, const QuadAdjacency& adj) { - if (j == edge_coord) { - // Top edge - if (i == 0) { - quad = 0; // North pole - i = j = 0; - } else { - quad = adj.up_neighbor; - long long new_j = edge_coord - i; - i = 0; - j = new_j; - } - return true; - } - if (i == edge_coord) { - // Right edge -> right neighbor - quad = adj.right_neighbor; - i = 0; - return true; - } - return false; -} - -// Handle edge overflow for lower hemisphere quads (6-10) -// Returns true if overflow was handled -inline bool handle_lower_edge(int& quad, long long& i, long long& j, - long long edge_coord, const QuadAdjacency& adj) { - if (i == edge_coord) { - // Right edge - if (j == 0) { - quad = 11; // South pole - i = j = 0; - } else { - quad = adj.right_neighbor; - long long new_i = edge_coord - j; - i = new_i; - j = 0; - } - return true; - } - if (j == edge_coord) { - // Top edge -> up neighbor - quad = adj.up_neighbor; - j = 0; - return true; - } - return false; -} - -bool handle_edge_overflow(int& quad, long long& i, long long& j, - int aperture, int resolution) { - long long edge_coord = get_max_ij(aperture, resolution) + 1; - - // Quick exit: not on edge - if (i != edge_coord && j != edge_coord) return false; - - // Polar quads don't overflow - if (quad < 1 || quad > 10) return false; - - const QuadAdjacency& adj = kQuadAdjacency[quad]; - - return adj.is_upper - ? handle_upper_edge(quad, i, j, edge_coord, adj) - : handle_lower_edge(quad, i, j, edge_coord, adj); -} - -void quad_xy_to_ij(int quad, double quad_x, double quad_y, - int aperture, int resolution, - int& out_quad, long long& out_i, long long& out_j) { - - // Aperture 7: exact-integer route. Clean unrotated Class I quantization at - // the substrate scale, DGGRID edgeTable quad canonicalization (an out-of-box - // coordinate belongs to the neighbouring quad), then (odd res) one exact - // aperture-7 coarsen -- yielding the exact resolution-r cell IJK. This keeps - // forward/inverse geometry consistent and replaces the float-rotation Class - // III quantization + handle_edge_overflow, which rounded boundary cells. - if (aperture == 7) { - if (resolution == 0) { - // Resolution 0: one cell per quad plus the two poles. Poles arise - // from the edge-overflow mapping (a point at an icosa vertex), so - // keep that here rather than the z7 hierarchy (empty at res 0). - quantize_class1(quad_x, quad_y, out_i, out_j); - out_quad = quad; - handle_edge_overflow(out_quad, out_i, out_j, 7, 0); - return; - } - long long S = ap7_classI_scale(resolution); - long long sub_i, sub_j; - quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), - sub_i, sub_j); - out_quad = quad; - dggrid_canonicalize_q2di(S, out_quad, sub_i, sub_j); - ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, out_i, out_j); - return; - } - - // Compute scale factor - double scale; - if (aperture == 3) { - scale = std::pow(kSqrt3, resolution); - } else if (aperture == 4) { - scale = std::pow(2.0, resolution); - } else { - throw std::runtime_error("quad_xy_to_ij: unsupported aperture"); - } - - double scaled_x = quad_x * scale; - double scaled_y = quad_y * scale; - - // Select quantization based on aperture and grid class - if (aperture == 4 || (aperture == 3 && resolution % 2 == 0)) { - // Class I quantization - quantize_class1(scaled_x, scaled_y, out_i, out_j); - } else { - // Class II quantization (aperture 3 odd resolutions) - quantize_class2(scaled_x, scaled_y, out_i, out_j); - } - - out_quad = quad; - handle_edge_overflow(out_quad, out_i, out_j, aperture, resolution); -} - -void icosa_tri_to_quad_ij(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, - int aperture, int resolution, - int& out_quad, long long& out_i, long long& out_j) { - int quad; - double quad_x, quad_y; - icosa_tri_to_quad_xy(icosa_triangle_face, icosa_triangle_x, icosa_triangle_y, quad, quad_x, quad_y); - quad_xy_to_ij(quad, quad_x, quad_y, aperture, resolution, out_quad, out_i, out_j); -} - -void quad_ij_to_xy(int quad, long long i, long long j, - int aperture, int resolution, - double& out_quad_x, double& out_quad_y) { - - double x, y; - inv_quantize_class1(i, j, x, y); - - // Compute inverse scale accounting for substrate - double scale; - if (aperture == 3) { - bool is_class1 = (resolution % 2 == 0); - scale = is_class1 - ? std::pow(kSqrt3, resolution) - : std::pow(kSqrt3, resolution + 1); // Class II substrate - } else if (aperture == 4) { - scale = std::pow(2.0, resolution); - } else if (aperture == 7) { - // Aperture 7: base scale * substrate multiplier - double base_scale = std::pow(std::sqrt(7.0), resolution); - bool is_class3i = (resolution % 2 == 0); - // Class III-I substrate is sqrt(7)x finer, Class III-II is sqrt(21)x finer - double substrate_mult = is_class3i ? kSqrt7 : kSqrt21; - scale = base_scale * substrate_mult; - } else { - throw std::runtime_error("quad_ij_to_xy: unsupported aperture"); - } - - out_quad_x = x / scale; - out_quad_y = y / scale; -} - -// Shared scale/class computation for the mixed 4/3 substrate: 2x per -// aperture-4 level, sqrt(3)x per aperture-3 level, mirroring -// calc_grid_params_ap43()'s cell-count formula in rcpp_cell.cpp -// (N = 10*4^level*3^(res-level)+2) so the quantized (i,j) match the grid -// that formula describes rather than a pure aperture-3 approximation. -namespace { -void ap43_scale_and_class(int resolution, int mixed_aperture_level, - double& out_scale, bool& out_use_class2) { - double scale = 1.0; - int ap3_count = 0; - for (int r = 1; r <= resolution; r++) { - if (r <= mixed_aperture_level) { - scale *= 2.0; - } else { - scale *= kSqrt3; - ap3_count++; - } - } - out_scale = scale; - out_use_class2 = (ap3_count % 2) == 1; -} -} // anonymous namespace - -void quad_xy_to_ij_ap43(int quad, double quad_x, double quad_y, - int resolution, int mixed_aperture_level, - int& out_quad, long long& out_i, long long& out_j) { - double scale; - bool use_class2; - ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); - - double scaled_x = quad_x * scale; - double scaled_y = quad_y * scale; - - if (use_class2) { - quantize_class2(scaled_x, scaled_y, out_i, out_j); - } else { - quantize_class1(scaled_x, scaled_y, out_i, out_j); - } - - out_quad = quad; - - // Class II quantization internally re-quantizes on a substrate that's - // sqrt(3)x finer than `scale` (see quantize_class2()), so the grid's - // true edge coordinate needs that same factor -- matching - // calc_max_grid_dim_ap43()'s "use_offset" boost in rcpp_cell.cpp. - double edge_scale = use_class2 ? scale * kSqrt3 : scale; - long long edge_coord = static_cast(edge_scale + 1e-9); - - // quantize_class2()'s internal rotate/requantize chain can overshoot the - // predicted edge_coord by a tie-breaking unit near a quad boundary - // (floating-point kSqrt3*kSqrt3 isn't exactly 3.0); handle_upper_edge()/ - // handle_lower_edge() below only match on exact equality, so clamp any - // overshoot back onto the boundary they expect. - if (out_i > edge_coord) out_i = edge_coord; - if (out_j > edge_coord) out_j = edge_coord; - - if ((out_i == edge_coord || out_j == edge_coord) && out_quad >= 1 && out_quad <= 10) { - const QuadAdjacency& adj = kQuadAdjacency[out_quad]; - if (adj.is_upper) { - handle_upper_edge(out_quad, out_i, out_j, edge_coord, adj); - } else { - handle_lower_edge(out_quad, out_i, out_j, edge_coord, adj); - } - } -} - -void quad_ij_to_xy_ap43(int quad, long long i, long long j, - int resolution, int mixed_aperture_level, - double& out_quad_x, double& out_quad_y) { - double x, y; - inv_quantize_class1(i, j, x, y); - - double scale; - bool use_class2; - ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); - double effective_scale = use_class2 ? scale * kSqrt3 : scale; - - out_quad_x = x / effective_scale; - out_quad_y = y / effective_scale; -} - -// ============================================================================ -// vertTable - Derived from First Principles -// ============================================================================ -// -// This table maps (quad, subTriRegion) -> (triNum, trans, rot60) -// -// Each quad is divided into 6 regions based on the hex geometry: -// Region 0: Upper (y > sqrt(3)*x AND y >= -sqrt(3)*x) -// Region 1: Upper-right (y <= sqrt(3)*x AND y >= 0) -// Region 2: Lower-right (y < 0 AND y > -sqrt(3)*x) -// Region 3: Lower (y <= -sqrt(3)*x AND y < sqrt(3)*x) -// Region 4: Lower-left (y >= sqrt(3)*x AND y < 0) -// Region 5: Upper-left (y >= 0 AND y < -sqrt(3)*x) -// -// ============================================================================ -// DERIVATION FROM FIRST PRINCIPLES -// ============================================================================ -// -// The vertTable is the inverse of the triTable. For each (quad, region), we -// need to find which triangle contains that region and what transformation -// brings Quad XY coordinates back to Icosa Triangle coordinates. -// -// ICOSAHEDRON STRUCTURE: -// --------------------- -// 20 triangular faces are numbered 0-19: -// - Faces 0-4: North cap (around vertex 0, touching north pole) -// - Faces 5-9: Upper-middle band (connecting north cap to lower band) -// - Faces 10-14: Lower-middle band (connecting upper band to south cap) -// - Faces 15-19: South cap (around vertex 11, touching south pole) -// -// QUAD STRUCTURE: -// --------------- -// 12 quads (rhombus shapes), each containing 2 triangles: -// - Quad 0: North pole vertex (special - not a rhombus) -// - Quads 1-5: Upper hemisphere, each contains triangles (n-1, n+4) for n=1..5 -// - Quads 6-10: Lower hemisphere, each contains triangles (n+4, n+9) for n=6..10 -// - Quad 11: South pole vertex (special - not a rhombus) -// -// TRIANGLE-TO-QUAD MAPPING (triTable, forward direction): -// ------------------------------------------------------- -// From the triTable, each triangle maps to a quad with a transformation: -// -// Triangle | Quad | Rotation | Translation -// ---------|------|----------|------------- -// 0 | 1 | 1 | (0, 0) <- primary -// 1 | 2 | 1 | (0, 0) <- primary -// 2 | 3 | 1 | (0, 0) <- primary -// 3 | 4 | 1 | (0, 0) <- primary -// 4 | 5 | 1 | (0, 0) <- primary -// 5 | 1 | 4 | (-0.5, -sin60) <- secondary -// 6 | 2 | 4 | (-0.5, -sin60) <- secondary -// 7 | 3 | 4 | (-0.5, -sin60) <- secondary -// 8 | 4 | 4 | (-0.5, -sin60) <- secondary -// 9 | 5 | 4 | (-0.5, -sin60) <- secondary -// 10 | 6 | 1 | (0, 0) <- primary -// 11 | 7 | 1 | (0, 0) <- primary -// 12 | 8 | 1 | (0, 0) <- primary -// 13 | 9 | 1 | (0, 0) <- primary -// 14 | 10 | 1 | (0, 0) <- primary -// 15 | 6 | 4 | (-0.5, -sin60) <- secondary -// 16 | 7 | 4 | (-0.5, -sin60) <- secondary -// 17 | 8 | 4 | (-0.5, -sin60) <- secondary -// 18 | 9 | 4 | (-0.5, -sin60) <- secondary -// 19 | 10 | 4 | (-0.5, -sin60) <- secondary -// -// Forward transform: rotate(rot * 60°) then subtract(trans) -// Inverse transform: add(trans) then rotate(-rot * 60°) -// -// QUAD-TO-TRIANGLE MAPPING (vertTable, inverse direction): -// -------------------------------------------------------- -// For each quad, the 6 regions map to triangles based on adjacency: -// -// Upper quads (1-5) - each contains primary triangle P and secondary S: -// Region 0: Primary triangle P (rot=-1, trans=negate of primary's) -// Region 1: Secondary triangle S (rot=-4, trans=negate of secondary's) -// Region 2: Lower-mid triangle (adjacent via icosahedron edge) -// Region 3: INVALID (extends beyond icosahedron) -// Region 4: Adjacent upper-mid secondary triangle -// Region 5: Previous quad's primary triangle -// -// Lower quads (6-10) - similar structure but mirrored: -// Region 0: Primary triangle (from lower-mid band) -// Region 1: Secondary triangle (from south cap) -// Region 2: Adjacent south cap triangle -// Region 3: Upper quad's secondary triangle -// Region 4: INVALID -// Region 5: Adjacent upper-mid secondary triangle -// -// ADJACENCY DERIVATION: -// --------------------- -// From icosahedron face definition: -// faces[20][3] = { -// {0,1,2},{0,2,3},{0,3,4},{0,4,5},{0,5,1}, // 0-4: North cap -// {6,2,1},{7,3,2},{8,4,3},{9,5,4},{10,1,5}, // 5-9: Upper-mid band -// {2,6,7},{3,7,8},{4,8,9},{5,9,10},{1,10,6}, // 10-14: Lower-mid band -// {11,7,6},{11,8,7},{11,9,8},{11,10,9},{11,6,10} // 15-19: South cap -// } -// -// Two faces are adjacent if they share 2 vertices. For each quad region, -// the adjacent triangle is determined by which face shares the edge -// corresponding to that region's direction. -// -// For quad q (1-5): -// - Region 0 → triangle (q-1): primary triangle of this quad -// - Region 1 → triangle (q+4): secondary triangle of this quad -// - Region 2 → triangle (q+9): lower-mid band (shares edge going southeast) -// - Region 3 → INVALID (no icosahedron face in this direction) -// - Region 4 → triangle ((q+3)%5+5): previous quad's secondary -// - Region 5 → triangle ((q-2+5)%5): next quad's primary -// -// For quad q (6-10): -// - Region 0 → triangle (q+4): lower-mid band primary -// - Region 1 → triangle (q+9): south cap secondary -// - Region 2 → triangle ((q-6+4)%5+15): adjacent south cap -// - Region 3 → triangle (q-6+10): this quad's lower-mid adjacent -// - Region 4 → INVALID -// - Region 5 → triangle ((q-6+4)%5+5): upper-mid secondary -// -// TRANSFORMATION DERIVATION: -// -------------------------- -// The inverse transformation parameters are computed as: -// - rot60: Negate the forward rotation -// - trans: The translation needed to move from Quad XY back to Icosa Triangle -// -// For a primary triangle (forward: rot=1, trans=(0,0)): -// Inverse: rot=-1, stored as 1 with sign applied during usage -// -// For a secondary triangle (forward: rot=4, trans=(-0.5,-sin60)): -// Inverse: rot=-4, trans is negated after rotation adjustment -// -// Cross-quad adjacencies require additional transformations based on how -// the triangles are oriented relative to each other. -// -// ============================================================================ - -struct VertTriVals { - int triNum; // Output triangle number - double trans_x; // Translation x (added to Quad XY before rotation) - double trans_y; // Translation y (added to Quad XY before rotation) - int rot60; // Number of 60-degree rotations (multiply by -60 for actual rotation) - bool keep; // Whether to keep this vertex -}; - -// vertTable[quad][subTri] - Derived from icosahedron geometry -// -// The derivation uses these key relationships: -// -// 1. Primary triangles of quads 1-5 are faces 0-4 (north cap) -// 2. Secondary triangles of quads 1-5 are faces 5-9 (upper-mid band) -// 3. Primary triangles of quads 6-10 are faces 10-14 (lower-mid band) -// 4. Secondary triangles of quads 6-10 are faces 15-19 (south cap) -// -// 5. Each region maps to an adjacent triangle with a specific transformation: -// - Region 0: The "upper" direction in Quad XY space -// - Region 1: The "upper-right" direction (60° clockwise from up) -// - Region 2: The "lower-right" direction (120° clockwise from up) -// - Region 3: The "lower" direction (180° from up) -// - Region 4: The "lower-left" direction (240° clockwise from up) -// - Region 5: The "upper-left" direction (300° clockwise from up) -// -// 6. The transformations are computed to reverse the forward triTable mapping -// while accounting for the hexagonal geometry. -// -static const VertTriVals kVertTable[12][6] = { - // ======================================================================== - // Quad 0 (North pole vertex) - // ======================================================================== - // The north pole (vertex 0) is surrounded by triangles 0-4. - // This is a special case where 5 triangles meet at a point. - // The 6 regions map to these 5 triangles with one invalid region. - // - // From vertex 0, going around counter-clockwise: - // Triangle 0: shares edge with triangles 4 and 1 - // Triangle 1: shares edge with triangles 0 and 2 - // Triangle 2: shares edge with triangles 1 and 3 - // Triangle 3: shares edge with triangles 2 and 4 - // Triangle 4: shares edge with triangles 3 and 0 - // - // Region assignments (empirically verified): - // Region 0 → Triangle 1 (rot=3) - // Region 1 → Triangle 0 (rot=2) - // Region 2 → Triangle 4 (rot=1) - // Region 3 → INVALID (pentagon vertex, no 6th triangle) - // Region 4 → Triangle 3 (rot=-1) - // Region 5 → Triangle 2 (rot=-2) - { - { 1, -0.5, -kSin60, 3, true}, // Region 0 → tri 1 - { 0, -1.0, 0.0, 2, true}, // Region 1 → tri 0 - { 4, -0.5, kSin60, 1, true}, // Region 2 → tri 4 - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 3, 1.0, 0.0, -1, true}, // Region 4 → tri 3 - { 2, 0.5, -kSin60, -2, true} // Region 5 → tri 2 - }, - - // ======================================================================== - // Quads 1-5 (Upper hemisphere) - // ======================================================================== - // Each quad q contains: - // - Primary triangle: (q-1) from north cap (faces 0-4) - // - Secondary triangle: (q+4) from upper-mid band (faces 5-9) - // - // The primary triangle transformation is: rot=1, trans=(0,0) - // The secondary triangle transformation is: rot=4, trans=(-0.5,-sin60) - // - // Inverse transformations: - // - For primary: add (0,0), rotate -1*60° = rotate(-60°) - // - For secondary: add (0.5,sin60) rotated, then rotate -4*60° - // - // Cross-quad adjacencies (computed from icosahedron edge sharing): - // Region 2: Lower-mid band triangle (q+9) with special transform - // Region 4: Previous quad's secondary triangle - // Region 5: Next quad's primary triangle (wrapping around) - // - // Quad 1: primary=tri0, secondary=tri5 - { - { 0, 0.0, 0.0, 1, true}, // Region 0 → tri 0 (primary) - { 5, -0.5, -kSin60, 4, true}, // Region 1 → tri 5 (secondary) - {14, -0.5, kSin60, 1, true}, // Region 2 → tri 14 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 9, 0.0, 0.0, 3, true}, // Region 4 → tri 9 (quad5's secondary) - { 4, 1.0, 0.0, 0, true} // Region 5 → tri 4 (quad5's primary) - }, - // Quad 2: primary=tri1, secondary=tri6 - { - { 1, 0.0, 0.0, 1, true}, // Region 0 → tri 1 (primary) - { 6, -0.5, -kSin60, 4, true}, // Region 1 → tri 6 (secondary) - {10, -0.5, kSin60, 1, true}, // Region 2 → tri 10 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 5, 0.0, 0.0, 3, true}, // Region 4 → tri 5 (quad1's secondary) - { 0, 1.0, 0.0, 0, true} // Region 5 → tri 0 (quad1's primary) - }, - // Quad 3: primary=tri2, secondary=tri7 - { - { 2, 0.0, 0.0, 1, true}, // Region 0 → tri 2 (primary) - { 7, -0.5, -kSin60, 4, true}, // Region 1 → tri 7 (secondary) - {11, -0.5, kSin60, 1, true}, // Region 2 → tri 11 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 6, 0.0, 0.0, 3, true}, // Region 4 → tri 6 (quad2's secondary) - { 1, 1.0, 0.0, 0, true} // Region 5 → tri 1 (quad2's primary) - }, - // Quad 4: primary=tri3, secondary=tri8 - { - { 3, 0.0, 0.0, 1, true}, // Region 0 → tri 3 (primary) - { 8, -0.5, -kSin60, 4, true}, // Region 1 → tri 8 (secondary) - {12, -0.5, kSin60, 1, true}, // Region 2 → tri 12 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 7, 0.0, 0.0, 3, true}, // Region 4 → tri 7 (quad3's secondary) - { 2, 1.0, 0.0, 0, true} // Region 5 → tri 2 (quad3's primary) - }, - // Quad 5: primary=tri4, secondary=tri9 - { - { 4, 0.0, 0.0, 1, true}, // Region 0 → tri 4 (primary) - { 9, -0.5, -kSin60, 4, true}, // Region 1 → tri 9 (secondary) - {13, -0.5, kSin60, 1, true}, // Region 2 → tri 13 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 8, 0.0, 0.0, 3, true}, // Region 4 → tri 8 (quad4's secondary) - { 3, 1.0, 0.0, 0, true} // Region 5 → tri 3 (quad4's primary) - }, - - // ======================================================================== - // Quads 6-10 (Lower hemisphere) - // ======================================================================== - // Each quad q contains: - // - Primary triangle: (q+4) from lower-mid band (faces 10-14) - // - Secondary triangle: (q+9) from south cap (faces 15-19) - // - // Lower hemisphere quads have different adjacency patterns: - // Region 0: Primary triangle (lower-mid band) - // Region 1: Secondary triangle (south cap) - // Region 2: Adjacent south cap triangle (wrapping) - // Region 3: Upper quad's lower-mid triangle (cross-hemisphere) - // Region 4: INVALID - // Region 5: Upper quad's secondary triangle - // - // Quad 6: primary=tri10, secondary=tri15 - { - {10, 0.0, 0.0, 1, true}, // Region 0 → tri 10 (primary) - {15, -0.5, -kSin60, 4, true}, // Region 1 → tri 15 (secondary) - {19, 0.0, 0.0, -1, true}, // Region 2 → tri 19 (adjacent south cap) - {14, -0.5, kSin60, 2, true}, // Region 3 → tri 14 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 5, 0.5, -kSin60, 4, true} // Region 5 → tri 5 (upper secondary) - }, - // Quad 7: primary=tri11, secondary=tri16 - { - {11, 0.0, 0.0, 1, true}, // Region 0 → tri 11 (primary) - {16, -0.5, -kSin60, 4, true}, // Region 1 → tri 16 (secondary) - {15, 0.0, 0.0, -1, true}, // Region 2 → tri 15 (adjacent south cap) - {10, -0.5, kSin60, 2, true}, // Region 3 → tri 10 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 6, 0.5, -kSin60, 4, true} // Region 5 → tri 6 (upper secondary) - }, - // Quad 8: primary=tri12, secondary=tri17 - { - {12, 0.0, 0.0, 1, true}, // Region 0 → tri 12 (primary) - {17, -0.5, -kSin60, 4, true}, // Region 1 → tri 17 (secondary) - {16, 0.0, 0.0, -1, true}, // Region 2 → tri 16 (adjacent south cap) - {11, -0.5, kSin60, 2, true}, // Region 3 → tri 11 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 7, 0.5, -kSin60, 4, true} // Region 5 → tri 7 (upper secondary) - }, - // Quad 9: primary=tri13, secondary=tri18 - { - {13, 0.0, 0.0, 1, true}, // Region 0 → tri 13 (primary) - {18, -0.5, -kSin60, 4, true}, // Region 1 → tri 18 (secondary) - {17, 0.0, 0.0, -1, true}, // Region 2 → tri 17 (adjacent south cap) - {12, -0.5, kSin60, 2, true}, // Region 3 → tri 12 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 8, 0.5, -kSin60, 4, true} // Region 5 → tri 8 (upper secondary) - }, - // Quad 10: primary=tri14, secondary=tri19 - { - {14, 0.0, 0.0, 1, true}, // Region 0 → tri 14 (primary) - {19, -0.5, -kSin60, 4, true}, // Region 1 → tri 19 (secondary) - {18, 0.0, 0.0, -1, true}, // Region 2 → tri 18 (adjacent south cap) - {13, -0.5, kSin60, 2, true}, // Region 3 → tri 13 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 9, 0.5, -kSin60, 4, true} // Region 5 → tri 9 (upper secondary) - }, - - // ======================================================================== - // Quad 11 (South pole vertex) - // ======================================================================== - // The south pole (vertex 11) is surrounded by triangles 15-19. - // This is a special case where 5 triangles meet at a point. - // The 6 regions map to these 5 triangles with one invalid region. - // - // From vertex 11, going around counter-clockwise: - // Triangle 15: shares edge with triangles 19 and 16 - // Triangle 16: shares edge with triangles 15 and 17 - // Triangle 17: shares edge with triangles 16 and 18 - // Triangle 18: shares edge with triangles 17 and 19 - // Triangle 19: shares edge with triangles 18 and 15 - // - // Region assignments (empirically verified): - // Region 0 → Triangle 17 (rot=3) - // Region 1 → Triangle 18 (rot=2) - // Region 2 → Triangle 19 (rot=1) - // Region 3 → Triangle 15 (rot=0) - // Region 4 → INVALID (pentagon vertex, no 6th triangle) - // Region 5 → Triangle 16 (rot=-2) - { - {17, -0.5, -kSin60, 3, true}, // Region 0 → tri 17 - {18, -1.0, 0.0, 2, true}, // Region 1 → tri 18 - {19, -0.5, kSin60, 1, true}, // Region 2 → tri 19 - {15, 0.5, kSin60, 0, true}, // Region 3 → tri 15 - {-1, 0.0, 0.0, 0, false}, // Region 4 → INVALID - {16, 0.5, -kSin60, -2, true} // Region 5 → tri 16 - } -}; - -// ============================================================================ -// Sub-triangle Region Detection -// ============================================================================ -// -// Divides the Quad XY coordinate space into 6 wedge-shaped regions emanating -// from the origin. The boundaries are lines at angles 0°, 60°, 120°, 180°, -// 240°, 300° from the positive x-axis. The key boundary is y = ±sqrt(3)*x. -// -// Region 0 (Upper) -// /\ -// Reg 5 / \ Reg 1 -// -----+----+----- -// Reg 4 \ / Reg 2 -// \/ -// Region 3 (Lower) -// -// Each region maps to a different triangle in the icosahedron. - -// Check if point is at origin (within tolerance) -inline bool is_origin(double x, double y, double tol) { - return std::fabs(x) <= tol && std::fabs(y) <= tol; -} - -// Compute which of 6 sub-regions a Quad XY point falls into -// Uses 6-way wedge classification based on y = ±sqrt(3)*x boundaries -static int compute_subtriangle(double x, double y) { - constexpr double tol = 1e-15; - - // Origin -> Region 1 (center/upper-right by convention) - if (is_origin(x, y, tol)) return 1; - - // Pre-compute boundary lines: y = ±sqrt(3)*x with tolerance - const double xs = kSqrt3 * x; - const double xs_plus = xs + tol; // y = sqrt(3)*x + tol - const double xs_minus = xs - tol; // y = sqrt(3)*x - tol - const double neg_xs_plus = -xs + tol; // y = -sqrt(3)*x + tol - const double neg_xs_minus = -xs - tol; // y = -sqrt(3)*x - tol - - // Region 0: Upper (above both diagonal lines) - if (y >= neg_xs_minus && y > xs_plus) return 0; - - // Region 1: Upper-right (below y=sqrt(3)*x, above y=0) - if (y <= xs_plus && y >= -tol) return 1; - - // Region 2: Lower-right (below y=0, above y=-sqrt(3)*x) - if (y < -tol && y > neg_xs_plus) return 2; - - // Region 3: Lower (below both diagonal lines) - if (y <= neg_xs_plus && y < xs_minus) return 3; - - // Region 4: Lower-left (above y=sqrt(3)*x, below y=0) - if (y >= xs_minus && y < -tol) return 4; - - // Region 5: Upper-left (above y=0, below y=-sqrt(3)*x) - if (y >= -tol && y < neg_xs_minus) return 5; - - // Fallback (should not occur for valid quad coordinates) - return 1; -} - -// Try to convert quad XY to icosa triangle coords. Returns true on success, -// false if the point is in an invalid region (e.g., outside the valid quad bounds). -bool try_quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, - int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { - if (quad < kMinQuad || quad > kMaxQuad) { - throw std::invalid_argument("try_quad_xy_to_icosa_tri: quad must be between 0 and 11"); - } - - // Detect which of 6 sub-regions the point falls into - int subTri = compute_subtriangle(quad_x, quad_y); - - // Look up transformation from vertTable - const VertTriVals& triVal = kVertTable[quad][subTri]; - - if (!triVal.keep || triVal.triNum < 0) { - // This region maps to an invalid/dropped vertex - return false; - } - - out_icosa_triangle_face = triVal.triNum; - - // Apply inverse transformation: - // coord += trans - // coord.rotate(rot60 * -60.0) // rotate by -60*rot60 degrees CCW - out_icosa_triangle_x = quad_x + triVal.trans_x; - out_icosa_triangle_y = quad_y + triVal.trans_y; - - // Rotate: rot60 * -60 degrees = -60 * rot60 degrees CCW - // Which is the same as 60 * rot60 degrees CW - // If rot60=4, rotate -240 degrees CCW = 120 degrees CW = -2 rotations of 60deg CCW - rotate_60deg_ccw(out_icosa_triangle_x, out_icosa_triangle_y, -triVal.rot60); - return true; -} - -void quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, - int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { - if (!try_quad_xy_to_icosa_tri(quad, quad_x, quad_y, out_icosa_triangle_face, out_icosa_triangle_x, out_icosa_triangle_y)) { - throw std::runtime_error("quad_xy_to_icosa_tri: point in invalid region"); - } -} - -} // namespace hexify +// coordinate_transforms.cpp - Convert between ISEA DGGS coordinate systems +// +// ============================================================================ +// COORDINATE TRANSFORMATION FLOW +// ============================================================================ +// +// This file implements the coordinate transformations between three systems: +// +// +---------------------+ +---------------+ +-----------+ +// | Icosa Triangle | --> | Quad XY | --> | Quad IJ | +// | (icosa_triangle_ | | (quad, | | (quad, | +// | face, _x, _y) | | quad_x, | | i, j) | +// +---------------------+ | quad_y) | +-----------+ +// | +---------------+ | +// v | v +// Icosahedral face Quad continuous Quad integer +// coordinates coordinates cell indices +// +// Icosa Triangle: Output from Snyder forward projection +// - icosa_triangle_face: Triangle index (0-19) +// - icosa_triangle_x, icosa_triangle_y: Normalized coords within triangle [0,1] +// +// Quad XY: Quad with continuous (double) coordinates +// - quad: Quad index (0-11, where 0=North pole, 11=South pole) +// - quad_x, quad_y: Continuous position within quad +// +// Quad IJ: Quad with integer cell indices (used for cell ID computation) +// - quad: Same as Quad XY +// - i, j: Integer cell coordinates (resolution-dependent) +// +// ============================================================================ +// ICOSAHEDRON GEOMETRY +// ============================================================================ +// +// The icosahedron has 20 triangular faces grouped into 12 quads: +// +// Quad 0 (North Pole) +// /\ +// / \ +// +----+----+----+----+----+ +// | Q1 | Q2 | Q3 | Q4 | Q5 | <- Upper hemisphere (quads 1-5) +// +----+----+----+----+----+ +// | Q6 | Q7 | Q8 | Q9 |Q10 | <- Lower hemisphere (quads 6-10) +// +----+----+----+----+----+ +// \ / +// \/ +// Quad 11 (South Pole) +// +// Each non-polar quad contains 2 triangles forming a rhombus. +// Triangles 0-4 and 5-9 map to quads 1-5 +// Triangles 10-14 and 15-19 map to quads 6-10 +// +// ============================================================================ +// QUANTIZATION CLASSES +// ============================================================================ +// +// Different apertures use different quantization schemes: +// +// Aperture 3: +// - Even resolutions: Class I (aligned hexagons) +// - Odd resolutions: Class II (rotated hexagons) +// +// Aperture 4: +// - All resolutions: Class I (aligned hexagons) +// +// Aperture 7: +// - Even resolutions: Class III-I +// - Odd resolutions: Class III-II +// +// Mathematical foundation from Sahr et al. publications on ISEA grids. +// +// Copyright (c) 2024 hexify authors. MIT License. + +#include "coordinate_transforms.h" +#include "cube_coordinates.h" +#include "ijk_coordinates.h" +#include "index_z7.h" +#include "constants.h" +#include +#include +#include + +namespace hexify { + +namespace { + +// ============================================================================ +// Triangle to Quad Mapping +// ============================================================================ +// +// Icosahedron face layout (20 triangles -> 12 quads): +// +// North Pole (Quad 0) at top, South Pole (Quad 11) at bottom. +// Quads 1-5: upper hemisphere, Quads 6-10: lower hemisphere. +// Each quad contains 2 triangles forming a rhombus shape. +// +// Each non-polar quad contains 2 triangles. The mapping specifies: +// - Which quad a triangle belongs to +// - Rotation and translation to align triangle coords with quad coords + +struct TriangleMapping { + int quad; // Target quad (1-10 for regular quads) + int sub_triangle; // 0 = primary, 1 = secondary (rotated/translated) + double offset_x; // X offset after rotation + double offset_y; // Y offset after rotation + int rotations; // Number of 60° clockwise rotations +}; + +// Mapping table derived from ISEA icosahedron geometry +// Triangle indices 0-19 map to quads 1-10 (polar quads 0,11 handled separately) +const TriangleMapping kTriangleMap[20] = { + // Upper cap triangles (0-4) -> quads 1-5, primary position + {1, 0, 0.0, 0.0, 1}, + {2, 0, 0.0, 0.0, 1}, + {3, 0, 0.0, 0.0, 1}, + {4, 0, 0.0, 0.0, 1}, + {5, 0, 0.0, 0.0, 1}, + // Upper-middle triangles (5-9) -> quads 1-5, secondary position + {1, 1, -0.5, -kSin60, 4}, + {2, 1, -0.5, -kSin60, 4}, + {3, 1, -0.5, -kSin60, 4}, + {4, 1, -0.5, -kSin60, 4}, + {5, 1, -0.5, -kSin60, 4}, + // Lower-middle triangles (10-14) -> quads 6-10, primary position + {6, 0, 0.0, 0.0, 1}, + {7, 0, 0.0, 0.0, 1}, + {8, 0, 0.0, 0.0, 1}, + {9, 0, 0.0, 0.0, 1}, + {10, 0, 0.0, 0.0, 1}, + // Lower cap triangles (15-19) -> quads 6-10, secondary position + {6, 1, -0.5, -kSin60, 4}, + {7, 1, -0.5, -kSin60, 4}, + {8, 1, -0.5, -kSin60, 4}, + {9, 1, -0.5, -kSin60, 4}, + {10, 1, -0.5, -kSin60, 4}, +}; + +// ============================================================================ +// Rotation Helper +// ============================================================================ + +void rotate_60deg_ccw(double& x, double& y, int n_rotations) { + // Each 60° counter-clockwise rotation: [cos(60) -sin(60); sin(60) cos(60)] + // cos(60°) = 0.5, sin(60°) = sqrt(3)/2 + constexpr double c60 = 0.5; + constexpr double s60 = kSin60; + + n_rotations = ((n_rotations % 6) + 6) % 6; // Normalize to 0-5 + + for (int i = 0; i < n_rotations; ++i) { + double nx = c60 * x - s60 * y; // counter-clockwise: x*cos - y*sin + double ny = s60 * x + c60 * y; // counter-clockwise: x*sin + y*cos + x = nx; + y = ny; + } +} + +// ============================================================================ +// Hex Quantization - Precise hexagonal grid rounding +// ============================================================================ +// This implementation handles all edge cases at hexagon boundaries correctly +// using a decision-tree approach that carefully handles the fractional parts +// of the continuous coordinates. This is more robust than simple cube-coordinate +// rounding at cell boundaries. + +// ============================================================================ +// Boundary Classification for Hex Quantization +// ============================================================================ +// +// The unit cell is divided into 6 regions based on fractional coordinates (frac_i, frac_j). +// Each region determines the (delta_i, delta_j) offset from the base cell (floor_i, floor_j). +// +// The regions form a hexagonal Voronoi partition: +// - Region A: frac_i < 1/3, frac_j < (1+frac_i)/2 -> (0, 0) +// - Region B: frac_i < 1/3, frac_j >= (1+frac_i)/2 -> (0, 1) +// - Region C: 1/3 <= frac_i < 1/2 -> complex boundary (see below) +// - Region D: 1/2 <= frac_i < 2/3 -> complex boundary (see below) +// - Region E: frac_i >= 2/3, frac_j < frac_i/2 -> (1, 0) +// - Region F: frac_i >= 2/3, frac_j >= frac_i/2 -> (1, 1) +// +// For regions C and D, the i-offset depends on whether frac_j falls in the +// "middle band" between two linear thresholds. + +// Classify which boundary region based on fractional coords +// Returns: 0=A, 1=B, 2=C_lower, 3=C_upper, 4=C_mid, 5=D_lower, 6=D_upper, 7=D_mid, 8=E, 9=F +inline int classify_hex_boundary(double frac_i, double frac_j) { + if (frac_i < 1.0/3.0) { + return (frac_j < (1.0 + frac_i) / 2.0) ? 0 : 1; // A or B + } + if (frac_i < 0.5) { + // Region C: thresholds at (1-frac_i) and (2*frac_i) + double lower_threshold = 1.0 - frac_i; + double upper_threshold = 2.0 * frac_i; + if (frac_j < lower_threshold) return 2; // C_lower: j=floor_j + if (frac_j >= upper_threshold) return 3; // C_upper: j=floor_j+1 + return 4; // C_mid: i=floor_i+1 + } + if (frac_i < 2.0/3.0) { + // Region D: thresholds at (2*frac_i-1) and (1-frac_i) + double lower_threshold = 2.0 * frac_i - 1.0; + double upper_threshold = 1.0 - frac_i; + if (frac_j <= lower_threshold) return 5; // D_lower: j=floor_j, i=floor_i+1 + if (frac_j >= upper_threshold) return 6; // D_upper: j=floor_j+1, i=floor_i+1 + return 7; // D_mid: i=floor_i + } + return (frac_j < frac_i / 2.0) ? 8 : 9; // E or F +} + +// Lookup table: boundary_region -> (delta_i, delta_j) offset +// Indexed by classify_hex_boundary() return value +static const int kBoundaryOffset[10][2] = { + {0, 0}, // 0: Region A + {0, 1}, // 1: Region B + {0, 0}, // 2: Region C_lower (j=floor_j) + {0, 1}, // 3: Region C_upper (j=floor_j+1) + {1, 0}, // 4: Region C_mid - special: j depends on frac_j < (1-frac_i) + {1, 0}, // 5: Region D_lower (j=floor_j) + {1, 1}, // 6: Region D_upper (j=floor_j+1) + {0, 0}, // 7: Region D_mid - special: j depends on frac_j < (1-frac_i) + {1, 0}, // 8: Region E + {1, 1}, // 9: Region F +}; + +// Fold i-coordinate across x-axis when x was negative +inline long long fold_i_negative_x(long long i, long long j) { + if ((j % 2) == 0) { + long long axis = j / 2; + return i - 2 * (i - axis); + } else { + long long axis = (j + 1) / 2; + return i - (2 * (i - axis) + 1); + } +} + +// Class I (flat-top) quantization +void quantize_class1(double x, double y, long long& out_i, long long& out_j) { + // Guard against NaN/Inf inputs to avoid undefined behavior in integer cast + if (!std::isfinite(x) || !std::isfinite(y)) { + out_i = 0; + out_j = 0; + return; + } + + // Work in positive quadrant + double abs_x = std::fabs(x); + double abs_y = std::fabs(y); + + // Convert to fractional hex indices + double idx_j = abs_y / kSin60; + double idx_i = abs_x + idx_j / 2.0; + + // Integer (floor) and fractional parts + long long floor_i = static_cast(idx_i); + long long floor_j = static_cast(idx_j); + double frac_i = idx_i - floor_i; + double frac_j = idx_j - floor_j; + + // Classify and look up base offset + int region = classify_hex_boundary(frac_i, frac_j); + long long delta_i = kBoundaryOffset[region][0]; + long long delta_j = kBoundaryOffset[region][1]; + + // Handle special cases where j depends on secondary threshold + if (region == 4) { // C_mid + delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; + } else if (region == 7) { // D_mid + delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; + } + + long long i_result = floor_i + delta_i; + long long j_result = floor_j + delta_j; + + // Fold back to original quadrant + if (x < 0.0) { + i_result = fold_i_negative_x(i_result, j_result); + } + if (y < 0.0) { + i_result = i_result - (2 * j_result + 1) / 2; + j_result = -j_result; + } + + out_i = i_result; + out_j = j_result; +} + +// Class I inverse: (i,j) to (x,y) +void inv_quantize_class1(long long i, long long j, double& x, double& y) { + cube_to_cartesian(static_cast(i), static_cast(j), x, y, kSin60); +} + +// Class II (pointy-top / 30° rotated) quantization +void quantize_class2(double x, double y, long long& out_i, long long& out_j) { + constexpr double angle = -kPi / 6.0; // -30° + double c = std::cos(angle); + double s = std::sin(angle); + + // Rotate to surrogate Class I orientation + double rx = x * c - y * s; + double ry = x * s + y * c; + + // Quantize in surrogate + long long sur_i, sur_j; + quantize_class1(rx, ry, sur_i, sur_j); + + // Get surrogate center and rotate back + double sur_x, sur_y; + inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); + + double back_x = sur_x * c + sur_y * s; // Rotate +30° + double back_y = -sur_x * s + sur_y * c; + + // Scale to substrate and re-quantize + quantize_class1(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); +} + + +// ============================================================================ +// Class III Quantization (Aperture 7) +// ============================================================================ +// Class III hexagons are rotated by arctan(sqrt(3)/5) ~= 19.1deg from Class I. +// This creates a grid where only 1/7 of substrate cells are valid. + +// Aperture 7 rotation angle in radians +constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; + +// Class III-I (even resolutions): Class I surrogate rotated by ~19.1deg +void quantize_class3i(double x, double y, long long& out_i, long long& out_j) { + const double c = std::cos(-kAp7RotRad); + const double s = std::sin(-kAp7RotRad); + + // Rotate to surrogate + double rx = x * c - y * s; + double ry = x * s + y * c; + + // Quantize in Class I surrogate + long long sur_i, sur_j; + quantize_class1(rx, ry, sur_i, sur_j); + + // Get surrogate center and rotate back + double sur_x, sur_y; + inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); + + double back_x = sur_x * c + sur_y * s; + double back_y = -sur_x * s + sur_y * c; + + // Scale to substrate (sqrt(7)x finer) and re-quantize + quantize_class1(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); +} + +// Class III-II (odd resolutions): Class II surrogate rotated by ~19.1deg +// The surrogate is a Class II grid (pointy-top, 30° rotated from Class I). +// We need to: +// 1. Rotate to surrogate frame (-19.1 degrees) +// 2. Quantize in Class II surrogate (which is Class I at -30 degrees) +// 3. Get the Class II surrogate center +// 4. Rotate back to original frame (+19.1 degrees) +// 5. Scale to Class I substrate (sqrt(21)x) and re-quantize +void quantize_class3ii(double x, double y, long long& out_i, long long& out_j) { + const double c_ap7 = std::cos(-kAp7RotRad); + const double s_ap7 = std::sin(-kAp7RotRad); + + // Step 1: Rotate to surrogate frame (-19.1 degrees) + double sur_x = x * c_ap7 - y * s_ap7; + double sur_y = x * s_ap7 + y * c_ap7; + + // Step 2: Quantize in Class II surrogate + // Class II = Class I rotated by -30 degrees + constexpr double c_30 = 0.866025403784438646763723170752936183; // cos(-30°) + constexpr double s_30 = -0.5; // sin(-30°) + + // Rotate to Class I orientation within the surrogate + double c1_x = sur_x * c_30 - sur_y * s_30; + double c1_y = sur_x * s_30 + sur_y * c_30; + + // Quantize in Class I + long long sur1_i, sur1_j; + quantize_class1(c1_x, c1_y, sur1_i, sur1_j); + + // Get Class I center + double sur1_cen_x, sur1_cen_y; + inv_quantize_class1(sur1_i, sur1_j, sur1_cen_x, sur1_cen_y); + + // Rotate back to Class II orientation (+30 degrees) + double c2_back_x = sur1_cen_x * c_30 + sur1_cen_y * s_30; + double c2_back_y = -sur1_cen_x * s_30 + sur1_cen_y * c_30; + + // Step 3: Rotate back to original frame (+19.1 degrees) + double back_x = c2_back_x * c_ap7 + c2_back_y * s_ap7; + double back_y = -c2_back_x * s_ap7 + c2_back_y * c_ap7; + + // Step 4: Scale to substrate (sqrt(21)x finer for Class III-II) and re-quantize + quantize_class1(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); +} + +// ============================================================================ +// Quad Edge Adjacency +// ============================================================================ +// +// When a cell falls on the edge of a quad, it may belong to an adjacent quad. +// This table defines the adjacency relationships. + +struct QuadAdjacency { + bool is_upper; // Upper hemisphere quad (1-5) vs lower (6-10) + int up_neighbor; // Quad above (for top edge overflow) + int right_neighbor; // Quad to the right (for right edge overflow) +}; + +const QuadAdjacency kQuadAdjacency[12] = { + {true, 0, 0}, // Quad 0: north pole (unused) + {true, 2, 6}, // Quad 1 + {true, 3, 7}, // Quad 2 + {true, 4, 8}, // Quad 3 + {true, 5, 9}, // Quad 4 + {true, 1, 10}, // Quad 5 + {false, 2, 7}, // Quad 6 + {false, 3, 8}, // Quad 7 + {false, 4, 9}, // Quad 8 + {false, 5, 10}, // Quad 9 + {false, 1, 6}, // Quad 10 + {false, 0, 0}, // Quad 11: south pole (unused) +}; + +} // anonymous namespace + +// ============================================================================ +// Aperture 7: Substrate ↔ True Surrogate Conversion +// ============================================================================ +// These functions convert between substrate (Class I) integer coordinates +// and the "true surrogate" coordinates used internally by DGGRID's Class III +// grids. The surrogate is a Class I (even res) or Class II (odd res) hex grid +// rotated by ~19.1° from the substrate frame. +// +// This is needed for finding neighbors: ±1 offsets in surrogate integer space +// correspond to actual adjacent ap7 cells. + +void substrate_to_surrogate_ap7(long long sub_i, long long sub_j, int resolution, + long long& sur_i, long long& sur_j) { + double x, y; + inv_quantize_class1(sub_i, sub_j, x, y); + + // Substrate Cartesian is sqrt(7)x (even) or sqrt(21)x (odd) larger + // than the surrogate scale. Divide down before rotation + quantization. + bool is_class3i = (resolution % 2 == 0); + double divisor = is_class3i ? kSqrt7 : kSqrt21; + x /= divisor; + y /= divisor; + + const double c = std::cos(-kAp7RotRad); + const double s = std::sin(-kAp7RotRad); + double rx = x * c - y * s; + double ry = x * s + y * c; + + if (is_class3i) { + quantize_class1(rx, ry, sur_i, sur_j); + } else { + constexpr double c_30 = 0.866025403784438646763723170752936183; + constexpr double s_30 = -0.5; + double c1x = rx * c_30 - ry * s_30; + double c1y = rx * s_30 + ry * c_30; + quantize_class1(c1x, c1y, sur_i, sur_j); + } +} + +void surrogate_to_substrate_ap7(long long sur_i, long long sur_j, int resolution, + long long& sub_i, long long& sub_j) { + double sx, sy; + inv_quantize_class1(sur_i, sur_j, sx, sy); + + bool is_class3i = (resolution % 2 == 0); + const double c_ap7 = std::cos(kAp7RotRad); + const double s_ap7 = std::sin(kAp7RotRad); + + double back_x, back_y; + if (is_class3i) { + back_x = sx * c_ap7 - sy * s_ap7; + back_y = sx * s_ap7 + sy * c_ap7; + quantize_class1(back_x * kSqrt7, back_y * kSqrt7, sub_i, sub_j); + } else { + constexpr double c_30 = 0.866025403784438646763723170752936183; + constexpr double s_30 = 0.5; + double c2x = sx * c_30 - sy * s_30; + double c2y = sx * s_30 + sy * c_30; + back_x = c2x * c_ap7 - c2y * s_ap7; + back_y = c2x * s_ap7 + c2y * c_ap7; + quantize_class1(back_x * kSqrt21, back_y * kSqrt21, sub_i, sub_j); + } +} + +// ============================================================================ +// Aperture 7: exact-integer surrogate machinery (matches DGGRID / H3) +// ============================================================================ +// The "surrogate" is hexify's canonical aperture-7 cell coordinate: the exact +// integer IJK of the resolution-r cell. It is obtained by a clean, unrotated +// Class I quantization of the shared quad_xy frame at the Class I substrate +// scale (7^numClassI = sqrt(7)^effectiveRes), DGGRID's edgeTable quad +// canonicalization, and, for odd resolutions, one exact aperture-7 coarsen +// (upAp7r). This replaces the earlier floating-point-rotation surrogate, whose +// re-quantization rounded boundary cells to a neighbour and diverged from the +// exact integer grid. + +namespace { + +// DgIDGGBase::edgeTable_[12]: quads 0/11 are pole placeholders (never occur). +struct DgQuadEdge { int quadNum; bool isType0; int loneVert, up, down, right, left; }; +const DgQuadEdge kDggridEdgeTable[12] = { + {0, true, 0, 0, 0, 0, 0}, + {1, true, 0, 2, 10, 6, 5}, + {2, true, 0, 3, 6, 7, 1}, + {3, true, 0, 4, 7, 8, 2}, + {4, true, 0, 5, 8, 9, 3}, + {5, true, 0, 1, 9, 10, 4}, + {6, false, 11, 2, 10, 7, 1}, + {7, false, 11, 3, 6, 8, 2}, + {8, false, 11, 4, 7, 9, 3}, + {9, false, 11, 5, 8, 10, 4}, + {10, false, 11, 1, 9, 6, 5}, + {11, false, 11, 0, 0, 0, 0}, +}; + +// Reassign an out-of-box Class I (i,j) to the quad that owns it. topEdge = +// 7^numClassI = maxI + 1 = maxJ + 1. Port of DgQ2DDtoIConverter's reassignment. +void dggrid_canonicalize_q2di(long long topEdge, int& quadNum, + long long& i, long long& j) { + const long long maxI = topEdge - 1, maxJ = topEdge - 1; + const long long topEdgeI = topEdge, topEdgeJ = topEdge; + + bool underI = i < 0, underJ = j < 0, overI = i > maxI, overJ = j > maxJ; + int numOver = (int)underI + (int)underJ + (int)overI + (int)overJ; + if (!numOver) return; + + const DgQuadEdge& ec = kDggridEdgeTable[quadNum]; + + if (overI && overJ) { + quadNum = ec.isType0 ? ec.up : ec.right; + i = 0; j = 0; + } else if (numOver > 1) { + return; // multi-underage: unreached for valid cell centres + } else if (underI) { + quadNum = ec.left; + if (ec.isType0) { long long ni = topEdgeJ - j + i, nj = topEdgeJ + i; i = ni; j = nj; } + else { i = topEdgeI + i; } + } else if (underJ) { + quadNum = ec.down; + if (ec.isType0) { j = topEdgeJ + j; } + else { long long ni = topEdgeJ + j, nj = (topEdgeI - i) + j; i = ni; j = nj; } + } else if (overI) { + if (ec.isType0) { quadNum = ec.right; i = i - topEdgeI; } + else if (j == 0) { quadNum = ec.loneVert; i = 0; j = 0; } + else { quadNum = ec.right; long long iOver = i - topEdgeI; long long ni = (topEdgeJ - j) + iOver; i = ni; j = iOver; } + } else if (overJ) { + if (!ec.isType0) { quadNum = ec.up; j = j - topEdgeJ; } + else if (i == 0) { quadNum = ec.loneVert; i = 0; j = 0; } + else { quadNum = ec.up; long long jOver = j - topEdgeJ; long long nj = topEdgeI - i + jOver; i = jOver; j = nj; } + } +} + +} // anonymous namespace + +long long ap7_classI_scale(int resolution) { // 7^numClassI, numClassI = (res+1)/2 + long long s = 1; + for (int k = 0, n = (resolution + 1) / 2; k < n; ++k) s *= 7; + return s; +} + +void ap7_substrate_to_surrogate_ijk(long long sub_i, long long sub_j, int resolution, + long long& sur_i, long long& sur_j) { + if (resolution % 2 == 0) { sur_i = sub_i; sur_j = sub_j; return; } + z7::IVec3D v(sub_i, sub_j, 0); + v.upAp7r(); + z7::IVec2D a(v); + sur_i = a.i(); + sur_j = a.j(); +} + +void ap7_surrogate_to_substrate_ijk(long long sur_i, long long sur_j, int resolution, + long long& sub_i, long long& sub_j) { + if (resolution % 2 == 0) { sub_i = sur_i; sub_j = sur_j; return; } + z7::IVec3D v(sur_i, sur_j, 0); + v.downAp7r(); + z7::IVec2D a(v); + sub_i = a.i(); + sub_j = a.j(); +} + +void quad_xy_to_surrogate_ij_ap7(double quad_x, double quad_y, int resolution, + long long& sur_i, long long& sur_j) { + long long S = ap7_classI_scale(resolution); + long long sub_i, sub_j; + quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), + sub_i, sub_j); + ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, sur_i, sur_j); +} + +void surrogate_ij_to_quad_xy_ap7(long long sur_i, long long sur_j, int resolution, + double& out_quad_x, double& out_quad_y) { + long long S = ap7_classI_scale(resolution); + long long sub_i, sub_j; + ap7_surrogate_to_substrate_ijk(sur_i, sur_j, resolution, sub_i, sub_j); + double cx, cy; + inv_quantize_class1(sub_i, sub_j, cx, cy); + out_quad_x = cx / static_cast(S); + out_quad_y = cy / static_cast(S); +} + +// ============================================================================ +// Public API Implementation +// ============================================================================ + +void icosa_tri_to_quad_xy(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, + int& out_quad, double& out_quad_x, double& out_quad_y) { + if (icosa_triangle_face < 0 || icosa_triangle_face >= 20) { + throw std::runtime_error("icosa_tri_to_quad_xy: icosa_triangle_face must be 0-19"); + } + + const TriangleMapping& mapping = kTriangleMap[icosa_triangle_face]; + + out_quad = mapping.quad; + out_quad_x = icosa_triangle_x; + out_quad_y = icosa_triangle_y; + + // Apply rotation then translation + rotate_60deg_ccw(out_quad_x, out_quad_y, mapping.rotations); + out_quad_x -= mapping.offset_x; + out_quad_y -= mapping.offset_y; +} + +long long get_max_ij(int aperture, int resolution) { + if (resolution <= 0) return 0; + + double factor; + if (aperture == 3) { + factor = std::pow(kSqrt3, resolution); + // Class II (odd res) uses finer substrate + if (resolution % 2 != 0) { + factor *= kSqrt3; + } + } else if (aperture == 4) { + factor = std::pow(2.0, resolution); + } else if (aperture == 7) { + factor = std::pow(std::sqrt(7.0), resolution); + // Class III-I (even res) uses sqrt(7) substrate, Class III-II (odd res) uses sqrt(21) + bool is_class3i = (resolution % 2 == 0); + factor *= is_class3i ? kSqrt7 : kSqrt21; + } else { + return 0; + } + + return static_cast(factor + 1e-9) - 1; +} + +// Handle edge overflow for upper hemisphere quads (1-5) +// Returns true if overflow was handled +inline bool handle_upper_edge(int& quad, long long& i, long long& j, + long long edge_coord, const QuadAdjacency& adj) { + if (j == edge_coord) { + // Top edge + if (i == 0) { + quad = 0; // North pole + i = j = 0; + } else { + quad = adj.up_neighbor; + long long new_j = edge_coord - i; + i = 0; + j = new_j; + } + return true; + } + if (i == edge_coord) { + // Right edge -> right neighbor + quad = adj.right_neighbor; + i = 0; + return true; + } + return false; +} + +// Handle edge overflow for lower hemisphere quads (6-10) +// Returns true if overflow was handled +inline bool handle_lower_edge(int& quad, long long& i, long long& j, + long long edge_coord, const QuadAdjacency& adj) { + if (i == edge_coord) { + // Right edge + if (j == 0) { + quad = 11; // South pole + i = j = 0; + } else { + quad = adj.right_neighbor; + long long new_i = edge_coord - j; + i = new_i; + j = 0; + } + return true; + } + if (j == edge_coord) { + // Top edge -> up neighbor + quad = adj.up_neighbor; + j = 0; + return true; + } + return false; +} + +bool handle_edge_overflow(int& quad, long long& i, long long& j, + int aperture, int resolution) { + long long edge_coord = get_max_ij(aperture, resolution) + 1; + + // Quick exit: not on edge + if (i != edge_coord && j != edge_coord) return false; + + // Polar quads don't overflow + if (quad < 1 || quad > 10) return false; + + const QuadAdjacency& adj = kQuadAdjacency[quad]; + + return adj.is_upper + ? handle_upper_edge(quad, i, j, edge_coord, adj) + : handle_lower_edge(quad, i, j, edge_coord, adj); +} + +void quad_xy_to_ij(int quad, double quad_x, double quad_y, + int aperture, int resolution, + int& out_quad, long long& out_i, long long& out_j) { + + // Aperture 7: exact-integer route. Clean unrotated Class I quantization at + // the substrate scale, DGGRID edgeTable quad canonicalization (an out-of-box + // coordinate belongs to the neighbouring quad), then (odd res) one exact + // aperture-7 coarsen -- yielding the exact resolution-r cell IJK. This keeps + // forward/inverse geometry consistent and replaces the float-rotation Class + // III quantization + handle_edge_overflow, which rounded boundary cells. + if (aperture == 7) { + if (resolution == 0) { + // Resolution 0: one cell per quad plus the two poles. Poles arise + // from the edge-overflow mapping (a point at an icosa vertex), so + // keep that here rather than the z7 hierarchy (empty at res 0). + quantize_class1(quad_x, quad_y, out_i, out_j); + out_quad = quad; + handle_edge_overflow(out_quad, out_i, out_j, 7, 0); + return; + } + long long S = ap7_classI_scale(resolution); + long long sub_i, sub_j; + quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), + sub_i, sub_j); + out_quad = quad; + dggrid_canonicalize_q2di(S, out_quad, sub_i, sub_j); + ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, out_i, out_j); + return; + } + + // Compute scale factor + double scale; + if (aperture == 3) { + scale = std::pow(kSqrt3, resolution); + } else if (aperture == 4) { + scale = std::pow(2.0, resolution); + } else { + throw std::runtime_error("quad_xy_to_ij: unsupported aperture"); + } + + double scaled_x = quad_x * scale; + double scaled_y = quad_y * scale; + + // Select quantization based on aperture and grid class + if (aperture == 4 || (aperture == 3 && resolution % 2 == 0)) { + // Class I quantization + quantize_class1(scaled_x, scaled_y, out_i, out_j); + } else { + // Class II quantization (aperture 3 odd resolutions) + quantize_class2(scaled_x, scaled_y, out_i, out_j); + } + + out_quad = quad; + handle_edge_overflow(out_quad, out_i, out_j, aperture, resolution); +} + +void icosa_tri_to_quad_ij(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, + int aperture, int resolution, + int& out_quad, long long& out_i, long long& out_j) { + int quad; + double quad_x, quad_y; + icosa_tri_to_quad_xy(icosa_triangle_face, icosa_triangle_x, icosa_triangle_y, quad, quad_x, quad_y); + quad_xy_to_ij(quad, quad_x, quad_y, aperture, resolution, out_quad, out_i, out_j); +} + +void quad_ij_to_xy(int quad, long long i, long long j, + int aperture, int resolution, + double& out_quad_x, double& out_quad_y) { + + double x, y; + inv_quantize_class1(i, j, x, y); + + // Compute inverse scale accounting for substrate + double scale; + if (aperture == 3) { + bool is_class1 = (resolution % 2 == 0); + scale = is_class1 + ? std::pow(kSqrt3, resolution) + : std::pow(kSqrt3, resolution + 1); // Class II substrate + } else if (aperture == 4) { + scale = std::pow(2.0, resolution); + } else if (aperture == 7) { + // Aperture 7: base scale * substrate multiplier + double base_scale = std::pow(std::sqrt(7.0), resolution); + bool is_class3i = (resolution % 2 == 0); + // Class III-I substrate is sqrt(7)x finer, Class III-II is sqrt(21)x finer + double substrate_mult = is_class3i ? kSqrt7 : kSqrt21; + scale = base_scale * substrate_mult; + } else { + throw std::runtime_error("quad_ij_to_xy: unsupported aperture"); + } + + out_quad_x = x / scale; + out_quad_y = y / scale; +} + +// Shared scale/class computation for the mixed 4/3 substrate: 2x per +// aperture-4 level, sqrt(3)x per aperture-3 level, mirroring +// calc_grid_params_ap43()'s cell-count formula in rcpp_cell.cpp +// (N = 10*4^level*3^(res-level)+2) so the quantized (i,j) match the grid +// that formula describes rather than a pure aperture-3 approximation. +namespace { +void ap43_scale_and_class(int resolution, int mixed_aperture_level, + double& out_scale, bool& out_use_class2) { + double scale = 1.0; + int ap3_count = 0; + for (int r = 1; r <= resolution; r++) { + if (r <= mixed_aperture_level) { + scale *= 2.0; + } else { + scale *= kSqrt3; + ap3_count++; + } + } + out_scale = scale; + out_use_class2 = (ap3_count % 2) == 1; +} +} // anonymous namespace + +void quad_xy_to_ij_ap43(int quad, double quad_x, double quad_y, + int resolution, int mixed_aperture_level, + int& out_quad, long long& out_i, long long& out_j) { + double scale; + bool use_class2; + ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); + + double scaled_x = quad_x * scale; + double scaled_y = quad_y * scale; + + if (use_class2) { + quantize_class2(scaled_x, scaled_y, out_i, out_j); + } else { + quantize_class1(scaled_x, scaled_y, out_i, out_j); + } + + out_quad = quad; + + // Class II quantization internally re-quantizes on a substrate that's + // sqrt(3)x finer than `scale` (see quantize_class2()), so the grid's + // true edge coordinate needs that same factor -- matching + // calc_max_grid_dim_ap43()'s "use_offset" boost in rcpp_cell.cpp. + double edge_scale = use_class2 ? scale * kSqrt3 : scale; + long long edge_coord = static_cast(edge_scale + 1e-9); + + // quantize_class2()'s internal rotate/requantize chain can overshoot the + // predicted edge_coord by a tie-breaking unit near a quad boundary + // (floating-point kSqrt3*kSqrt3 isn't exactly 3.0); handle_upper_edge()/ + // handle_lower_edge() below only match on exact equality, so clamp any + // overshoot back onto the boundary they expect. + if (out_i > edge_coord) out_i = edge_coord; + if (out_j > edge_coord) out_j = edge_coord; + + if ((out_i == edge_coord || out_j == edge_coord) && out_quad >= 1 && out_quad <= 10) { + const QuadAdjacency& adj = kQuadAdjacency[out_quad]; + if (adj.is_upper) { + handle_upper_edge(out_quad, out_i, out_j, edge_coord, adj); + } else { + handle_lower_edge(out_quad, out_i, out_j, edge_coord, adj); + } + } +} + +void quad_ij_to_xy_ap43(int quad, long long i, long long j, + int resolution, int mixed_aperture_level, + double& out_quad_x, double& out_quad_y) { + double x, y; + inv_quantize_class1(i, j, x, y); + + double scale; + bool use_class2; + ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); + double effective_scale = use_class2 ? scale * kSqrt3 : scale; + + out_quad_x = x / effective_scale; + out_quad_y = y / effective_scale; +} + +// ============================================================================ +// vertTable - Derived from First Principles +// ============================================================================ +// +// This table maps (quad, subTriRegion) -> (triNum, trans, rot60) +// +// Each quad is divided into 6 regions based on the hex geometry: +// Region 0: Upper (y > sqrt(3)*x AND y >= -sqrt(3)*x) +// Region 1: Upper-right (y <= sqrt(3)*x AND y >= 0) +// Region 2: Lower-right (y < 0 AND y > -sqrt(3)*x) +// Region 3: Lower (y <= -sqrt(3)*x AND y < sqrt(3)*x) +// Region 4: Lower-left (y >= sqrt(3)*x AND y < 0) +// Region 5: Upper-left (y >= 0 AND y < -sqrt(3)*x) +// +// ============================================================================ +// DERIVATION FROM FIRST PRINCIPLES +// ============================================================================ +// +// The vertTable is the inverse of the triTable. For each (quad, region), we +// need to find which triangle contains that region and what transformation +// brings Quad XY coordinates back to Icosa Triangle coordinates. +// +// ICOSAHEDRON STRUCTURE: +// --------------------- +// 20 triangular faces are numbered 0-19: +// - Faces 0-4: North cap (around vertex 0, touching north pole) +// - Faces 5-9: Upper-middle band (connecting north cap to lower band) +// - Faces 10-14: Lower-middle band (connecting upper band to south cap) +// - Faces 15-19: South cap (around vertex 11, touching south pole) +// +// QUAD STRUCTURE: +// --------------- +// 12 quads (rhombus shapes), each containing 2 triangles: +// - Quad 0: North pole vertex (special - not a rhombus) +// - Quads 1-5: Upper hemisphere, each contains triangles (n-1, n+4) for n=1..5 +// - Quads 6-10: Lower hemisphere, each contains triangles (n+4, n+9) for n=6..10 +// - Quad 11: South pole vertex (special - not a rhombus) +// +// TRIANGLE-TO-QUAD MAPPING (triTable, forward direction): +// ------------------------------------------------------- +// From the triTable, each triangle maps to a quad with a transformation: +// +// Triangle | Quad | Rotation | Translation +// ---------|------|----------|------------- +// 0 | 1 | 1 | (0, 0) <- primary +// 1 | 2 | 1 | (0, 0) <- primary +// 2 | 3 | 1 | (0, 0) <- primary +// 3 | 4 | 1 | (0, 0) <- primary +// 4 | 5 | 1 | (0, 0) <- primary +// 5 | 1 | 4 | (-0.5, -sin60) <- secondary +// 6 | 2 | 4 | (-0.5, -sin60) <- secondary +// 7 | 3 | 4 | (-0.5, -sin60) <- secondary +// 8 | 4 | 4 | (-0.5, -sin60) <- secondary +// 9 | 5 | 4 | (-0.5, -sin60) <- secondary +// 10 | 6 | 1 | (0, 0) <- primary +// 11 | 7 | 1 | (0, 0) <- primary +// 12 | 8 | 1 | (0, 0) <- primary +// 13 | 9 | 1 | (0, 0) <- primary +// 14 | 10 | 1 | (0, 0) <- primary +// 15 | 6 | 4 | (-0.5, -sin60) <- secondary +// 16 | 7 | 4 | (-0.5, -sin60) <- secondary +// 17 | 8 | 4 | (-0.5, -sin60) <- secondary +// 18 | 9 | 4 | (-0.5, -sin60) <- secondary +// 19 | 10 | 4 | (-0.5, -sin60) <- secondary +// +// Forward transform: rotate(rot * 60°) then subtract(trans) +// Inverse transform: add(trans) then rotate(-rot * 60°) +// +// QUAD-TO-TRIANGLE MAPPING (vertTable, inverse direction): +// -------------------------------------------------------- +// For each quad, the 6 regions map to triangles based on adjacency: +// +// Upper quads (1-5) - each contains primary triangle P and secondary S: +// Region 0: Primary triangle P (rot=-1, trans=negate of primary's) +// Region 1: Secondary triangle S (rot=-4, trans=negate of secondary's) +// Region 2: Lower-mid triangle (adjacent via icosahedron edge) +// Region 3: INVALID (extends beyond icosahedron) +// Region 4: Adjacent upper-mid secondary triangle +// Region 5: Previous quad's primary triangle +// +// Lower quads (6-10) - similar structure but mirrored: +// Region 0: Primary triangle (from lower-mid band) +// Region 1: Secondary triangle (from south cap) +// Region 2: Adjacent south cap triangle +// Region 3: Upper quad's secondary triangle +// Region 4: INVALID +// Region 5: Adjacent upper-mid secondary triangle +// +// ADJACENCY DERIVATION: +// --------------------- +// From icosahedron face definition: +// faces[20][3] = { +// {0,1,2},{0,2,3},{0,3,4},{0,4,5},{0,5,1}, // 0-4: North cap +// {6,2,1},{7,3,2},{8,4,3},{9,5,4},{10,1,5}, // 5-9: Upper-mid band +// {2,6,7},{3,7,8},{4,8,9},{5,9,10},{1,10,6}, // 10-14: Lower-mid band +// {11,7,6},{11,8,7},{11,9,8},{11,10,9},{11,6,10} // 15-19: South cap +// } +// +// Two faces are adjacent if they share 2 vertices. For each quad region, +// the adjacent triangle is determined by which face shares the edge +// corresponding to that region's direction. +// +// For quad q (1-5): +// - Region 0 → triangle (q-1): primary triangle of this quad +// - Region 1 → triangle (q+4): secondary triangle of this quad +// - Region 2 → triangle (q+9): lower-mid band (shares edge going southeast) +// - Region 3 → INVALID (no icosahedron face in this direction) +// - Region 4 → triangle ((q+3)%5+5): previous quad's secondary +// - Region 5 → triangle ((q-2+5)%5): next quad's primary +// +// For quad q (6-10): +// - Region 0 → triangle (q+4): lower-mid band primary +// - Region 1 → triangle (q+9): south cap secondary +// - Region 2 → triangle ((q-6+4)%5+15): adjacent south cap +// - Region 3 → triangle (q-6+10): this quad's lower-mid adjacent +// - Region 4 → INVALID +// - Region 5 → triangle ((q-6+4)%5+5): upper-mid secondary +// +// TRANSFORMATION DERIVATION: +// -------------------------- +// The inverse transformation parameters are computed as: +// - rot60: Negate the forward rotation +// - trans: The translation needed to move from Quad XY back to Icosa Triangle +// +// For a primary triangle (forward: rot=1, trans=(0,0)): +// Inverse: rot=-1, stored as 1 with sign applied during usage +// +// For a secondary triangle (forward: rot=4, trans=(-0.5,-sin60)): +// Inverse: rot=-4, trans is negated after rotation adjustment +// +// Cross-quad adjacencies require additional transformations based on how +// the triangles are oriented relative to each other. +// +// ============================================================================ + +struct VertTriVals { + int triNum; // Output triangle number + double trans_x; // Translation x (added to Quad XY before rotation) + double trans_y; // Translation y (added to Quad XY before rotation) + int rot60; // Number of 60-degree rotations (multiply by -60 for actual rotation) + bool keep; // Whether to keep this vertex +}; + +// vertTable[quad][subTri] - Derived from icosahedron geometry +// +// The derivation uses these key relationships: +// +// 1. Primary triangles of quads 1-5 are faces 0-4 (north cap) +// 2. Secondary triangles of quads 1-5 are faces 5-9 (upper-mid band) +// 3. Primary triangles of quads 6-10 are faces 10-14 (lower-mid band) +// 4. Secondary triangles of quads 6-10 are faces 15-19 (south cap) +// +// 5. Each region maps to an adjacent triangle with a specific transformation: +// - Region 0: The "upper" direction in Quad XY space +// - Region 1: The "upper-right" direction (60° clockwise from up) +// - Region 2: The "lower-right" direction (120° clockwise from up) +// - Region 3: The "lower" direction (180° from up) +// - Region 4: The "lower-left" direction (240° clockwise from up) +// - Region 5: The "upper-left" direction (300° clockwise from up) +// +// 6. The transformations are computed to reverse the forward triTable mapping +// while accounting for the hexagonal geometry. +// +static const VertTriVals kVertTable[12][6] = { + // ======================================================================== + // Quad 0 (North pole vertex) + // ======================================================================== + // The north pole (vertex 0) is surrounded by triangles 0-4. + // This is a special case where 5 triangles meet at a point. + // The 6 regions map to these 5 triangles with one invalid region. + // + // From vertex 0, going around counter-clockwise: + // Triangle 0: shares edge with triangles 4 and 1 + // Triangle 1: shares edge with triangles 0 and 2 + // Triangle 2: shares edge with triangles 1 and 3 + // Triangle 3: shares edge with triangles 2 and 4 + // Triangle 4: shares edge with triangles 3 and 0 + // + // Region assignments (empirically verified): + // Region 0 → Triangle 1 (rot=3) + // Region 1 → Triangle 0 (rot=2) + // Region 2 → Triangle 4 (rot=1) + // Region 3 → INVALID (pentagon vertex, no 6th triangle) + // Region 4 → Triangle 3 (rot=-1) + // Region 5 → Triangle 2 (rot=-2) + { + { 1, -0.5, -kSin60, 3, true}, // Region 0 → tri 1 + { 0, -1.0, 0.0, 2, true}, // Region 1 → tri 0 + { 4, -0.5, kSin60, 1, true}, // Region 2 → tri 4 + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 3, 1.0, 0.0, -1, true}, // Region 4 → tri 3 + { 2, 0.5, -kSin60, -2, true} // Region 5 → tri 2 + }, + + // ======================================================================== + // Quads 1-5 (Upper hemisphere) + // ======================================================================== + // Each quad q contains: + // - Primary triangle: (q-1) from north cap (faces 0-4) + // - Secondary triangle: (q+4) from upper-mid band (faces 5-9) + // + // The primary triangle transformation is: rot=1, trans=(0,0) + // The secondary triangle transformation is: rot=4, trans=(-0.5,-sin60) + // + // Inverse transformations: + // - For primary: add (0,0), rotate -1*60° = rotate(-60°) + // - For secondary: add (0.5,sin60) rotated, then rotate -4*60° + // + // Cross-quad adjacencies (computed from icosahedron edge sharing): + // Region 2: Lower-mid band triangle (q+9) with special transform + // Region 4: Previous quad's secondary triangle + // Region 5: Next quad's primary triangle (wrapping around) + // + // Quad 1: primary=tri0, secondary=tri5 + { + { 0, 0.0, 0.0, 1, true}, // Region 0 → tri 0 (primary) + { 5, -0.5, -kSin60, 4, true}, // Region 1 → tri 5 (secondary) + {14, -0.5, kSin60, 1, true}, // Region 2 → tri 14 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 9, 0.0, 0.0, 3, true}, // Region 4 → tri 9 (quad5's secondary) + { 4, 1.0, 0.0, 0, true} // Region 5 → tri 4 (quad5's primary) + }, + // Quad 2: primary=tri1, secondary=tri6 + { + { 1, 0.0, 0.0, 1, true}, // Region 0 → tri 1 (primary) + { 6, -0.5, -kSin60, 4, true}, // Region 1 → tri 6 (secondary) + {10, -0.5, kSin60, 1, true}, // Region 2 → tri 10 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 5, 0.0, 0.0, 3, true}, // Region 4 → tri 5 (quad1's secondary) + { 0, 1.0, 0.0, 0, true} // Region 5 → tri 0 (quad1's primary) + }, + // Quad 3: primary=tri2, secondary=tri7 + { + { 2, 0.0, 0.0, 1, true}, // Region 0 → tri 2 (primary) + { 7, -0.5, -kSin60, 4, true}, // Region 1 → tri 7 (secondary) + {11, -0.5, kSin60, 1, true}, // Region 2 → tri 11 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 6, 0.0, 0.0, 3, true}, // Region 4 → tri 6 (quad2's secondary) + { 1, 1.0, 0.0, 0, true} // Region 5 → tri 1 (quad2's primary) + }, + // Quad 4: primary=tri3, secondary=tri8 + { + { 3, 0.0, 0.0, 1, true}, // Region 0 → tri 3 (primary) + { 8, -0.5, -kSin60, 4, true}, // Region 1 → tri 8 (secondary) + {12, -0.5, kSin60, 1, true}, // Region 2 → tri 12 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 7, 0.0, 0.0, 3, true}, // Region 4 → tri 7 (quad3's secondary) + { 2, 1.0, 0.0, 0, true} // Region 5 → tri 2 (quad3's primary) + }, + // Quad 5: primary=tri4, secondary=tri9 + { + { 4, 0.0, 0.0, 1, true}, // Region 0 → tri 4 (primary) + { 9, -0.5, -kSin60, 4, true}, // Region 1 → tri 9 (secondary) + {13, -0.5, kSin60, 1, true}, // Region 2 → tri 13 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 8, 0.0, 0.0, 3, true}, // Region 4 → tri 8 (quad4's secondary) + { 3, 1.0, 0.0, 0, true} // Region 5 → tri 3 (quad4's primary) + }, + + // ======================================================================== + // Quads 6-10 (Lower hemisphere) + // ======================================================================== + // Each quad q contains: + // - Primary triangle: (q+4) from lower-mid band (faces 10-14) + // - Secondary triangle: (q+9) from south cap (faces 15-19) + // + // Lower hemisphere quads have different adjacency patterns: + // Region 0: Primary triangle (lower-mid band) + // Region 1: Secondary triangle (south cap) + // Region 2: Adjacent south cap triangle (wrapping) + // Region 3: Upper quad's lower-mid triangle (cross-hemisphere) + // Region 4: INVALID + // Region 5: Upper quad's secondary triangle + // + // Quad 6: primary=tri10, secondary=tri15 + { + {10, 0.0, 0.0, 1, true}, // Region 0 → tri 10 (primary) + {15, -0.5, -kSin60, 4, true}, // Region 1 → tri 15 (secondary) + {19, 0.0, 0.0, -1, true}, // Region 2 → tri 19 (adjacent south cap) + {14, -0.5, kSin60, 2, true}, // Region 3 → tri 14 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 5, 0.5, -kSin60, 4, true} // Region 5 → tri 5 (upper secondary) + }, + // Quad 7: primary=tri11, secondary=tri16 + { + {11, 0.0, 0.0, 1, true}, // Region 0 → tri 11 (primary) + {16, -0.5, -kSin60, 4, true}, // Region 1 → tri 16 (secondary) + {15, 0.0, 0.0, -1, true}, // Region 2 → tri 15 (adjacent south cap) + {10, -0.5, kSin60, 2, true}, // Region 3 → tri 10 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 6, 0.5, -kSin60, 4, true} // Region 5 → tri 6 (upper secondary) + }, + // Quad 8: primary=tri12, secondary=tri17 + { + {12, 0.0, 0.0, 1, true}, // Region 0 → tri 12 (primary) + {17, -0.5, -kSin60, 4, true}, // Region 1 → tri 17 (secondary) + {16, 0.0, 0.0, -1, true}, // Region 2 → tri 16 (adjacent south cap) + {11, -0.5, kSin60, 2, true}, // Region 3 → tri 11 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 7, 0.5, -kSin60, 4, true} // Region 5 → tri 7 (upper secondary) + }, + // Quad 9: primary=tri13, secondary=tri18 + { + {13, 0.0, 0.0, 1, true}, // Region 0 → tri 13 (primary) + {18, -0.5, -kSin60, 4, true}, // Region 1 → tri 18 (secondary) + {17, 0.0, 0.0, -1, true}, // Region 2 → tri 17 (adjacent south cap) + {12, -0.5, kSin60, 2, true}, // Region 3 → tri 12 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 8, 0.5, -kSin60, 4, true} // Region 5 → tri 8 (upper secondary) + }, + // Quad 10: primary=tri14, secondary=tri19 + { + {14, 0.0, 0.0, 1, true}, // Region 0 → tri 14 (primary) + {19, -0.5, -kSin60, 4, true}, // Region 1 → tri 19 (secondary) + {18, 0.0, 0.0, -1, true}, // Region 2 → tri 18 (adjacent south cap) + {13, -0.5, kSin60, 2, true}, // Region 3 → tri 13 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 9, 0.5, -kSin60, 4, true} // Region 5 → tri 9 (upper secondary) + }, + + // ======================================================================== + // Quad 11 (South pole vertex) + // ======================================================================== + // The south pole (vertex 11) is surrounded by triangles 15-19. + // This is a special case where 5 triangles meet at a point. + // The 6 regions map to these 5 triangles with one invalid region. + // + // From vertex 11, going around counter-clockwise: + // Triangle 15: shares edge with triangles 19 and 16 + // Triangle 16: shares edge with triangles 15 and 17 + // Triangle 17: shares edge with triangles 16 and 18 + // Triangle 18: shares edge with triangles 17 and 19 + // Triangle 19: shares edge with triangles 18 and 15 + // + // Region assignments (empirically verified): + // Region 0 → Triangle 17 (rot=3) + // Region 1 → Triangle 18 (rot=2) + // Region 2 → Triangle 19 (rot=1) + // Region 3 → Triangle 15 (rot=0) + // Region 4 → INVALID (pentagon vertex, no 6th triangle) + // Region 5 → Triangle 16 (rot=-2) + { + {17, -0.5, -kSin60, 3, true}, // Region 0 → tri 17 + {18, -1.0, 0.0, 2, true}, // Region 1 → tri 18 + {19, -0.5, kSin60, 1, true}, // Region 2 → tri 19 + {15, 0.5, kSin60, 0, true}, // Region 3 → tri 15 + {-1, 0.0, 0.0, 0, false}, // Region 4 → INVALID + {16, 0.5, -kSin60, -2, true} // Region 5 → tri 16 + } +}; + +// ============================================================================ +// Sub-triangle Region Detection +// ============================================================================ +// +// Divides the Quad XY coordinate space into 6 wedge-shaped regions emanating +// from the origin. The boundaries are lines at angles 0°, 60°, 120°, 180°, +// 240°, 300° from the positive x-axis. The key boundary is y = ±sqrt(3)*x. +// +// Region 0 (Upper) +// /\ +// Reg 5 / \ Reg 1 +// -----+----+----- +// Reg 4 \ / Reg 2 +// \/ +// Region 3 (Lower) +// +// Each region maps to a different triangle in the icosahedron. + +// Check if point is at origin (within tolerance) +inline bool is_origin(double x, double y, double tol) { + return std::fabs(x) <= tol && std::fabs(y) <= tol; +} + +// Compute which of 6 sub-regions a Quad XY point falls into +// Uses 6-way wedge classification based on y = ±sqrt(3)*x boundaries +static int compute_subtriangle(double x, double y) { + constexpr double tol = 1e-15; + + // Origin -> Region 1 (center/upper-right by convention) + if (is_origin(x, y, tol)) return 1; + + // Pre-compute boundary lines: y = ±sqrt(3)*x with tolerance + const double xs = kSqrt3 * x; + const double xs_plus = xs + tol; // y = sqrt(3)*x + tol + const double xs_minus = xs - tol; // y = sqrt(3)*x - tol + const double neg_xs_plus = -xs + tol; // y = -sqrt(3)*x + tol + const double neg_xs_minus = -xs - tol; // y = -sqrt(3)*x - tol + + // Region 0: Upper (above both diagonal lines) + if (y >= neg_xs_minus && y > xs_plus) return 0; + + // Region 1: Upper-right (below y=sqrt(3)*x, above y=0) + if (y <= xs_plus && y >= -tol) return 1; + + // Region 2: Lower-right (below y=0, above y=-sqrt(3)*x) + if (y < -tol && y > neg_xs_plus) return 2; + + // Region 3: Lower (below both diagonal lines) + if (y <= neg_xs_plus && y < xs_minus) return 3; + + // Region 4: Lower-left (above y=sqrt(3)*x, below y=0) + if (y >= xs_minus && y < -tol) return 4; + + // Region 5: Upper-left (above y=0, below y=-sqrt(3)*x) + if (y >= -tol && y < neg_xs_minus) return 5; + + // Fallback (should not occur for valid quad coordinates) + return 1; +} + +// Try to convert quad XY to icosa triangle coords. Returns true on success, +// false if the point is in an invalid region (e.g., outside the valid quad bounds). +bool try_quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, + int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { + if (quad < kMinQuad || quad > kMaxQuad) { + throw std::invalid_argument("try_quad_xy_to_icosa_tri: quad must be between 0 and 11"); + } + + // Detect which of 6 sub-regions the point falls into + int subTri = compute_subtriangle(quad_x, quad_y); + + // Look up transformation from vertTable + const VertTriVals& triVal = kVertTable[quad][subTri]; + + if (!triVal.keep || triVal.triNum < 0) { + // This region maps to an invalid/dropped vertex + return false; + } + + out_icosa_triangle_face = triVal.triNum; + + // Apply inverse transformation: + // coord += trans + // coord.rotate(rot60 * -60.0) // rotate by -60*rot60 degrees CCW + out_icosa_triangle_x = quad_x + triVal.trans_x; + out_icosa_triangle_y = quad_y + triVal.trans_y; + + // Rotate: rot60 * -60 degrees = -60 * rot60 degrees CCW + // Which is the same as 60 * rot60 degrees CW + // If rot60=4, rotate -240 degrees CCW = 120 degrees CW = -2 rotations of 60deg CCW + rotate_60deg_ccw(out_icosa_triangle_x, out_icosa_triangle_y, -triVal.rot60); + return true; +} + +void quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, + int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { + if (!try_quad_xy_to_icosa_tri(quad, quad_x, quad_y, out_icosa_triangle_face, out_icosa_triangle_x, out_icosa_triangle_y)) { + throw std::runtime_error("quad_xy_to_icosa_tri: point in invalid region"); + } +} + +} // namespace hexify diff --git a/src/grid_math.h b/src/grid_math.h index 177a1b3..ec01a9c 100644 --- a/src/grid_math.h +++ b/src/grid_math.h @@ -1,429 +1,429 @@ -// grid_math.h - Shared hexagonal grid mathematics -// -// This module provides the fundamental mathematical operations for hexagonal -// discrete global grid systems (DGGS). It consolidates operations that were -// previously duplicated across aperture-specific files. -// -// ============================================================================ -// HEXAGON ROTATION CLASSES -// ============================================================================ -// -// Hexagonal grids come in different orientations called "Rotation Classes" -// (terminology from Sahr et al. 2003): -// -// ROTATION CLASS I (0-degree, flat-top) -// -------------------------------------- -// - Standard orientation (0 degrees) -// - Used by aperture-4 always -// - Flat edge on top -// -// ROTATION CLASS II (30-degree, pointy-top) -// ------------------------------------------ -// - Rotated 30 degrees from Rotation Class I -// - Alternates with Rotation Class I in aperture-3 -// - Pointed vertex on top -// -// ROTATION CLASS III (Aperture-7 only) -// ------------------------------------- -// Rotated by arctan(sqrt(3/7)) = ~19.1 degrees from the base orientation. -// Two variants alternate by resolution: -// - Rotation Class III-A (even res): Rotation Class I + 19.1 deg = ~19 deg -// - Rotation Class III-B (odd res): Rotation Class II + 19.1 deg = ~49 deg -// -// ============================================================================ -// COORDINATE SYSTEMS -// ============================================================================ -// -// CUBE COORDINATES (q, r, s) -// -------------------------- -// Three-axis system where q + r + s = 0. Provides elegant nearest-neighbor -// rounding via the "round and fix" algorithm. Used internally for quantization. -// -// +s -// | -// | -// +q ----+---- -q -// | -// | -// -s -// -// OFFSET COORDINATES (i, j) -// ------------------------- -// Two-axis system output by quantization. Maps directly to cell indices. -// In our implementation: i = q, j = r (from cube coordinates). -// -// ============================================================================ -// SURROGATE-SUBSTRATE PATTERN -// ============================================================================ -// -// Non-Rotation-Class-I grids use a "surrogate-substrate" quantization pattern: -// -// 1. ROTATE input point to align with a "surrogate" Rotation Class I grid -// 2. QUANTIZE in the surrogate grid (using Rotation Class I math) -// 3. GET CENTER of the surrogate cell -// 4. ROTATE BACK to original orientation -// 5. SCALE UP to a finer "substrate" grid and re-quantize -// -// This pattern produces coordinates compatible with hierarchical ISEA grids. -// -// Scale factors by rotation class: -// - Rotation Class II: sqrt(3) = ~1.732 (from Rotation Class I surrogate) -// - Rotation Class III-A: sqrt(7) = ~2.646 (aperture-7, even resolutions) -// - Rotation Class III-B: sqrt(21) = ~4.583 (aperture-7, odd resolutions) -// -// Copyright (c) 2024 hexify authors. MIT License. - -#ifndef HEXIFY_GRID_MATH_H -#define HEXIFY_GRID_MATH_H - -#include "cube_coordinates.h" -#include "constants.h" -#include - -namespace hexify { - -// ============================================================================ -// Rotation Utilities -// ============================================================================ - -/** - * Rotate a 2D point by an angle (in radians). - * - * @param x Input/output X coordinate - * @param y Input/output Y coordinate - * @param angle Rotation angle in radians (positive = counter-clockwise) - */ -inline void rotate_point(double& x, double& y, double angle_rad) { - double c = std::cos(angle_rad); - double s = std::sin(angle_rad); - double new_x = x * c - y * s; - double new_y = x * s + y * c; - x = new_x; - y = new_y; -} - -/** - * Rotate a 2D point using pre-computed sin/cos values. - * More efficient when the same rotation is applied many times. - * - * @param x Input/output X coordinate - * @param y Input/output Y coordinate - * @param cos_a Cosine of the rotation angle - * @param sin_a Sine of the rotation angle - */ -inline void rotate_point_precomputed(double& x, double& y, - double cos_a, double sin_a) { - double new_x = x * cos_a - y * sin_a; - double new_y = x * sin_a + y * cos_a; - x = new_x; - y = new_y; -} - -/** - * Rotate a 2D point by the inverse angle (negate sin). - * Used for "rotate back" operations in surrogate-substrate pattern. - */ -inline void rotate_point_inverse(double& x, double& y, - double cos_a, double sin_a) { - double new_x = x * cos_a + y * sin_a; - double new_y = -x * sin_a + y * cos_a; - x = new_x; - y = new_y; -} - -// ============================================================================ -// Rotation Class I (0-Degree, Flat-Top) Hexagon Quantization -// ============================================================================ -// -// This is the fundamental quantization algorithm. All other rotation classes -// use this as a building block via the surrogate-substrate pattern. - -/** - * Quantize a point to the nearest Rotation Class I (0-degree, flat-top) hexagon. - * - * Algorithm: - * 1. Convert Cartesian (x, y) to cube coordinates (q, r, s) - * 2. Round each cube coordinate to nearest integer - * 3. Fix rounding to maintain q + r + s = 0 constraint - * 4. Output offset coordinates (i, j) = (q, r) - * - * @param x X coordinate in hex grid space - * @param y Y coordinate in hex grid space - * @param out_i Output: column index (q from cube coords) - * @param out_j Output: row index (r from cube coords) - */ -inline void quantize_rotation_classI(double x, double y, - long long& out_i, long long& out_j) { - // Guard against NaN/Inf inputs to avoid UB in CubeCoord::round_to_nearest()'s - // std::llround() call. Matches coordinate_transforms.cpp::quantize_class1. - if (!std::isfinite(x) || !std::isfinite(y)) { - out_i = 0; - out_j = 0; - return; - } - - // Convert to cube coordinates using flat-top layout - CubeCoord cube = cartesian_to_cube(x, y, kSqrt3); - - // Round to nearest hex center (maintains q + r + s = 0) - cube.round_to_nearest(); - - // Extract offset coordinates - out_i = static_cast(cube.q); - out_j = static_cast(cube.r); -} - -/** - * Get the Cartesian center of a Rotation Class I (0-degree, flat-top) hexagon. - * - * @param i Column index - * @param j Row index - * @param out_x Output: X coordinate of cell center - * @param out_y Output: Y coordinate of cell center - */ -inline void center_rotation_classI(long long i, long long j, - double& out_x, double& out_y) { - cube_to_cartesian(static_cast(i), static_cast(j), - out_x, out_y, kSin60); -} - -// ============================================================================ -// Rotation Class II (30-Degree, Pointy-Top) Hexagon Quantization -// ============================================================================ -// -// Rotation Class II hexagons are rotated 30 degrees from Rotation Class I. -// Uses the surrogate-substrate pattern. - -/** - * Quantize a point to the nearest Rotation Class II (30-degree, pointy-top) hexagon. - * - * Uses surrogate-substrate pattern: - * 1. Rotate by -30 deg to align with Rotation Class I surrogate - * 2. Quantize in Rotation Class I grid - * 3. Get surrogate center, rotate back by +30 deg - * 4. Scale by sqrt(3) to substrate, re-quantize in Rotation Class I grid - * - * @param x X coordinate in hex grid space - * @param y Y coordinate in hex grid space - * @param out_i Output: column index in substrate coordinates - * @param out_j Output: row index in substrate coordinates - */ -inline void quantize_rotation_classII(double x, double y, - long long& out_i, long long& out_j) { - // Pre-computed rotation constants for -30 degrees - constexpr double cos_neg30 = kCos30; // cos(-30) = cos(30) - constexpr double sin_neg30 = -kSin30; // sin(-30) = -sin(30) = -0.5 - - // Step 1: Rotate to Rotation Class I surrogate frame (-30 degrees) - double sur_x = x * cos_neg30 - y * sin_neg30; - double sur_y = x * sin_neg30 + y * cos_neg30; - - // Step 2: Quantize in Rotation Class I surrogate - long long sur_i, sur_j; - quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); - - // Step 3: Get surrogate center - double cen_x, cen_y; - center_rotation_classI(sur_i, sur_j, cen_x, cen_y); - - // Step 4: Rotate center back to original frame (+30 degrees) - // Inverse rotation: cos same, sin negated - double back_x = cen_x * cos_neg30 + cen_y * sin_neg30; - double back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; - - // Step 5: Scale to substrate (sqrt(3) finer) and re-quantize - quantize_rotation_classI(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); -} - -// ============================================================================ -// Rotation Class III (Aperture-7) Hexagon Quantization -// ============================================================================ -// -// Aperture-7 uses hexagons rotated by arctan(sqrt(3/7)) = ~19.1 degrees. -// Two variants alternate by resolution: -// - Rotation Class III-A (~19 deg, even res): Rotation Class I + 19.1 deg -// - Rotation Class III-B (~49 deg, odd res): Rotation Class II + 19.1 deg - -// Pre-computed constants for ~19.1 degree rotation -namespace detail { - // arctan(sqrt(3/7)) in radians - constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; - constexpr double kCos19 = 0.9449111825230680440492389263705078; // cos(19.106...°) - constexpr double kSin19 = 0.3273268353539885718950317563490135; // sin(19.106...°) -} - -/** - * Quantize to Rotation Class III-A (~19-degree) hexagon (aperture-7, even resolutions). - * - * Surrogate: Rotation Class I grid rotated by -19.1 degrees - * Substrate: sqrt(7) times finer than surrogate - */ -inline void quantize_rotation_classIII_A(double x, double y, - long long& out_i, long long& out_j) { - using namespace detail; - - // Step 1: Rotate to Rotation Class I surrogate frame (-19.1 degrees) - double sur_x = x * kCos19 + y * kSin19; // cos(-a) = cos(a) - double sur_y = -x * kSin19 + y * kCos19; // sin(-a) = -sin(a) - - // Step 2: Quantize in Rotation Class I surrogate - long long sur_i, sur_j; - quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); - - // Step 3: Get surrogate center - double cen_x, cen_y; - center_rotation_classI(sur_i, sur_j, cen_x, cen_y); - - // Step 4: Rotate back to original frame (+19.1 degrees) - double back_x = cen_x * kCos19 - cen_y * kSin19; - double back_y = cen_x * kSin19 + cen_y * kCos19; - - // Step 5: Scale to substrate (sqrt(7) finer) and re-quantize - quantize_rotation_classI(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); -} - -/** - * Quantize to Rotation Class III-B (~49-degree) hexagon (aperture-7, odd resolutions). - * - * Surrogate: Rotation Class II grid rotated by -19.1 degrees - * (equivalent to Rotation Class I grid rotated by -49.1 degrees) - * Substrate: sqrt(21) times finer than surrogate - */ -inline void quantize_rotation_classIII_B(double x, double y, - long long& out_i, long long& out_j) { - using namespace detail; - - // Step 1: Rotate to surrogate frame (-19.1 degrees) - double sur_x = x * kCos19 + y * kSin19; - double sur_y = -x * kSin19 + y * kCos19; - - // Step 2: The surrogate is Rotation Class II (rotated 30 deg from Rotation Class I). - // To quantize, first rotate to Rotation Class I orientation. - constexpr double cos_neg30 = kCos30; - constexpr double sin_neg30 = -0.5; - - double rotated_x = sur_x * cos_neg30 - sur_y * sin_neg30; - double rotated_y = sur_x * sin_neg30 + sur_y * cos_neg30; - - // Step 3: Quantize in Rotation Class I grid - long long sur_i, sur_j; - quantize_rotation_classI(rotated_x, rotated_y, sur_i, sur_j); - - // Step 4: Get Rotation Class I center - double cen_x, cen_y; - center_rotation_classI(sur_i, sur_j, cen_x, cen_y); - - // Step 5: Rotate back to Rotation Class II orientation (+30 degrees) - double rotated_back_x = cen_x * cos_neg30 + cen_y * sin_neg30; - double rotated_back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; - - // Step 6: Rotate back to original frame (+19.1 degrees) - double back_x = rotated_back_x * kCos19 - rotated_back_y * kSin19; - double back_y = rotated_back_x * kSin19 + rotated_back_y * kCos19; - - // Step 7: Scale to substrate (sqrt(21) finer) and re-quantize - quantize_rotation_classI(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); -} - -// ============================================================================ -// Hexagon Corner Generation -// ============================================================================ - -/** - * Generate the 6 corners of a hexagon given its center and radius. - * - * @param cx Center X coordinate - * @param cy Center Y coordinate - * @param radius Distance from center to each corner - * @param rotation_deg Rotation offset in degrees (0 = flat-top, 30 = pointy-top) - * @param out_x Output array of 6 X coordinates - * @param out_y Output array of 6 Y coordinates - */ -inline void generate_hex_corners(double cx, double cy, double radius, - double rotation_deg, - double* out_x, double* out_y) { - double rotation_rad = rotation_deg * kDegToRad; - - // Generate 6 vertices starting at top, counter-clockwise - for (int k = 0; k < 6; ++k) { - double angle = kPiOver2 + rotation_rad + k * kPiOver3; - out_x[k] = cx + radius * std::cos(angle); - out_y[k] = cy + radius * std::sin(angle); - } -} - -// ============================================================================ -// Aperture Scale Factors -// ============================================================================ - -/** - * Get the cumulative scale factor for a given aperture and resolution. - * - * @param aperture Aperture type (3, 4, or 7) - * @param resolution Grid resolution level - * @return Scale factor to multiply coordinates by - */ -inline double aperture_scale(int aperture, int resolution) { - switch (aperture) { - case 3: return std::pow(kSqrt3, resolution); - case 4: return std::pow(2.0, resolution); - case 7: return std::pow(kSqrt7, resolution); - default: return 1.0; - } -} - -/** - * Get the substrate scale multiplier for the current orientation class. - * - * @param aperture Aperture type - * @param resolution Grid resolution level - * @return Additional scale factor for substrate coordinates - */ -inline double substrate_multiplier(int aperture, int resolution) { - bool is_even = (resolution % 2 == 0); - - switch (aperture) { - case 3: - // Rotation Class I (even): no extra scale - // Rotation Class II (odd): sqrt(3) substrate - return is_even ? 1.0 : kSqrt3; - case 4: - // Always Rotation Class I, no extra scale - return 1.0; - case 7: - // Rotation Class III-A (even): sqrt(7) substrate - // Rotation Class III-B (odd): sqrt(21) substrate - return is_even ? kSqrt7 : kSqrt21; - default: - return 1.0; - } -} - -/** - * Get the rotation offset in degrees for hexagon corners. - * - * @param aperture Aperture type - * @param resolution Grid resolution level - * @return Rotation offset in degrees - */ -inline double corner_rotation_deg(int aperture, int resolution) { - bool is_even = (resolution % 2 == 0); - - switch (aperture) { - case 3: - // Rotation Class I: 0 deg, Rotation Class II: 30 deg - return is_even ? 0.0 : 30.0; - case 4: - // Always Rotation Class I - return 0.0; - case 7: - // Rotation Class III-A: ~19.1 deg, Rotation Class III-B: ~49.1 deg - return is_even ? kAp7RotDeg : (kAp7RotDeg + 30.0); - default: - return 0.0; - } -} - -} // namespace hexify - -#endif // HEXIFY_GRID_MATH_H +// grid_math.h - Shared hexagonal grid mathematics +// +// This module provides the fundamental mathematical operations for hexagonal +// discrete global grid systems (DGGS). It consolidates operations that were +// previously duplicated across aperture-specific files. +// +// ============================================================================ +// HEXAGON ROTATION CLASSES +// ============================================================================ +// +// Hexagonal grids come in different orientations called "Rotation Classes" +// (terminology from Sahr et al. 2003): +// +// ROTATION CLASS I (0-degree, flat-top) +// -------------------------------------- +// - Standard orientation (0 degrees) +// - Used by aperture-4 always +// - Flat edge on top +// +// ROTATION CLASS II (30-degree, pointy-top) +// ------------------------------------------ +// - Rotated 30 degrees from Rotation Class I +// - Alternates with Rotation Class I in aperture-3 +// - Pointed vertex on top +// +// ROTATION CLASS III (Aperture-7 only) +// ------------------------------------- +// Rotated by arctan(sqrt(3/7)) = ~19.1 degrees from the base orientation. +// Two variants alternate by resolution: +// - Rotation Class III-A (even res): Rotation Class I + 19.1 deg = ~19 deg +// - Rotation Class III-B (odd res): Rotation Class II + 19.1 deg = ~49 deg +// +// ============================================================================ +// COORDINATE SYSTEMS +// ============================================================================ +// +// CUBE COORDINATES (q, r, s) +// -------------------------- +// Three-axis system where q + r + s = 0. Provides elegant nearest-neighbor +// rounding via the "round and fix" algorithm. Used internally for quantization. +// +// +s +// | +// | +// +q ----+---- -q +// | +// | +// -s +// +// OFFSET COORDINATES (i, j) +// ------------------------- +// Two-axis system output by quantization. Maps directly to cell indices. +// In our implementation: i = q, j = r (from cube coordinates). +// +// ============================================================================ +// SURROGATE-SUBSTRATE PATTERN +// ============================================================================ +// +// Non-Rotation-Class-I grids use a "surrogate-substrate" quantization pattern: +// +// 1. ROTATE input point to align with a "surrogate" Rotation Class I grid +// 2. QUANTIZE in the surrogate grid (using Rotation Class I math) +// 3. GET CENTER of the surrogate cell +// 4. ROTATE BACK to original orientation +// 5. SCALE UP to a finer "substrate" grid and re-quantize +// +// This pattern produces coordinates compatible with hierarchical ISEA grids. +// +// Scale factors by rotation class: +// - Rotation Class II: sqrt(3) = ~1.732 (from Rotation Class I surrogate) +// - Rotation Class III-A: sqrt(7) = ~2.646 (aperture-7, even resolutions) +// - Rotation Class III-B: sqrt(21) = ~4.583 (aperture-7, odd resolutions) +// +// Copyright (c) 2024 hexify authors. MIT License. + +#ifndef HEXIFY_GRID_MATH_H +#define HEXIFY_GRID_MATH_H + +#include "cube_coordinates.h" +#include "constants.h" +#include + +namespace hexify { + +// ============================================================================ +// Rotation Utilities +// ============================================================================ + +/** + * Rotate a 2D point by an angle (in radians). + * + * @param x Input/output X coordinate + * @param y Input/output Y coordinate + * @param angle Rotation angle in radians (positive = counter-clockwise) + */ +inline void rotate_point(double& x, double& y, double angle_rad) { + double c = std::cos(angle_rad); + double s = std::sin(angle_rad); + double new_x = x * c - y * s; + double new_y = x * s + y * c; + x = new_x; + y = new_y; +} + +/** + * Rotate a 2D point using pre-computed sin/cos values. + * More efficient when the same rotation is applied many times. + * + * @param x Input/output X coordinate + * @param y Input/output Y coordinate + * @param cos_a Cosine of the rotation angle + * @param sin_a Sine of the rotation angle + */ +inline void rotate_point_precomputed(double& x, double& y, + double cos_a, double sin_a) { + double new_x = x * cos_a - y * sin_a; + double new_y = x * sin_a + y * cos_a; + x = new_x; + y = new_y; +} + +/** + * Rotate a 2D point by the inverse angle (negate sin). + * Used for "rotate back" operations in surrogate-substrate pattern. + */ +inline void rotate_point_inverse(double& x, double& y, + double cos_a, double sin_a) { + double new_x = x * cos_a + y * sin_a; + double new_y = -x * sin_a + y * cos_a; + x = new_x; + y = new_y; +} + +// ============================================================================ +// Rotation Class I (0-Degree, Flat-Top) Hexagon Quantization +// ============================================================================ +// +// This is the fundamental quantization algorithm. All other rotation classes +// use this as a building block via the surrogate-substrate pattern. + +/** + * Quantize a point to the nearest Rotation Class I (0-degree, flat-top) hexagon. + * + * Algorithm: + * 1. Convert Cartesian (x, y) to cube coordinates (q, r, s) + * 2. Round each cube coordinate to nearest integer + * 3. Fix rounding to maintain q + r + s = 0 constraint + * 4. Output offset coordinates (i, j) = (q, r) + * + * @param x X coordinate in hex grid space + * @param y Y coordinate in hex grid space + * @param out_i Output: column index (q from cube coords) + * @param out_j Output: row index (r from cube coords) + */ +inline void quantize_rotation_classI(double x, double y, + long long& out_i, long long& out_j) { + // Guard against NaN/Inf inputs to avoid UB in CubeCoord::round_to_nearest()'s + // std::llround() call. Matches coordinate_transforms.cpp::quantize_class1. + if (!std::isfinite(x) || !std::isfinite(y)) { + out_i = 0; + out_j = 0; + return; + } + + // Convert to cube coordinates using flat-top layout + CubeCoord cube = cartesian_to_cube(x, y, kSqrt3); + + // Round to nearest hex center (maintains q + r + s = 0) + cube.round_to_nearest(); + + // Extract offset coordinates + out_i = static_cast(cube.q); + out_j = static_cast(cube.r); +} + +/** + * Get the Cartesian center of a Rotation Class I (0-degree, flat-top) hexagon. + * + * @param i Column index + * @param j Row index + * @param out_x Output: X coordinate of cell center + * @param out_y Output: Y coordinate of cell center + */ +inline void center_rotation_classI(long long i, long long j, + double& out_x, double& out_y) { + cube_to_cartesian(static_cast(i), static_cast(j), + out_x, out_y, kSin60); +} + +// ============================================================================ +// Rotation Class II (30-Degree, Pointy-Top) Hexagon Quantization +// ============================================================================ +// +// Rotation Class II hexagons are rotated 30 degrees from Rotation Class I. +// Uses the surrogate-substrate pattern. + +/** + * Quantize a point to the nearest Rotation Class II (30-degree, pointy-top) hexagon. + * + * Uses surrogate-substrate pattern: + * 1. Rotate by -30 deg to align with Rotation Class I surrogate + * 2. Quantize in Rotation Class I grid + * 3. Get surrogate center, rotate back by +30 deg + * 4. Scale by sqrt(3) to substrate, re-quantize in Rotation Class I grid + * + * @param x X coordinate in hex grid space + * @param y Y coordinate in hex grid space + * @param out_i Output: column index in substrate coordinates + * @param out_j Output: row index in substrate coordinates + */ +inline void quantize_rotation_classII(double x, double y, + long long& out_i, long long& out_j) { + // Pre-computed rotation constants for -30 degrees + constexpr double cos_neg30 = kCos30; // cos(-30) = cos(30) + constexpr double sin_neg30 = -kSin30; // sin(-30) = -sin(30) = -0.5 + + // Step 1: Rotate to Rotation Class I surrogate frame (-30 degrees) + double sur_x = x * cos_neg30 - y * sin_neg30; + double sur_y = x * sin_neg30 + y * cos_neg30; + + // Step 2: Quantize in Rotation Class I surrogate + long long sur_i, sur_j; + quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); + + // Step 3: Get surrogate center + double cen_x, cen_y; + center_rotation_classI(sur_i, sur_j, cen_x, cen_y); + + // Step 4: Rotate center back to original frame (+30 degrees) + // Inverse rotation: cos same, sin negated + double back_x = cen_x * cos_neg30 + cen_y * sin_neg30; + double back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; + + // Step 5: Scale to substrate (sqrt(3) finer) and re-quantize + quantize_rotation_classI(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); +} + +// ============================================================================ +// Rotation Class III (Aperture-7) Hexagon Quantization +// ============================================================================ +// +// Aperture-7 uses hexagons rotated by arctan(sqrt(3/7)) = ~19.1 degrees. +// Two variants alternate by resolution: +// - Rotation Class III-A (~19 deg, even res): Rotation Class I + 19.1 deg +// - Rotation Class III-B (~49 deg, odd res): Rotation Class II + 19.1 deg + +// Pre-computed constants for ~19.1 degree rotation +namespace detail { + // arctan(sqrt(3)/5) in radians + constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; + constexpr double kCos19 = 0.9449111825230680440492389263705078; // cos(19.106...°) + constexpr double kSin19 = 0.3273268353539885718950317563490135; // sin(19.106...°) +} + +/** + * Quantize to Rotation Class III-A (~19-degree) hexagon (aperture-7, even resolutions). + * + * Surrogate: Rotation Class I grid rotated by -19.1 degrees + * Substrate: sqrt(7) times finer than surrogate + */ +inline void quantize_rotation_classIII_A(double x, double y, + long long& out_i, long long& out_j) { + using namespace detail; + + // Step 1: Rotate to Rotation Class I surrogate frame (-19.1 degrees) + double sur_x = x * kCos19 + y * kSin19; // cos(-a) = cos(a) + double sur_y = -x * kSin19 + y * kCos19; // sin(-a) = -sin(a) + + // Step 2: Quantize in Rotation Class I surrogate + long long sur_i, sur_j; + quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); + + // Step 3: Get surrogate center + double cen_x, cen_y; + center_rotation_classI(sur_i, sur_j, cen_x, cen_y); + + // Step 4: Rotate back to original frame (+19.1 degrees) + double back_x = cen_x * kCos19 - cen_y * kSin19; + double back_y = cen_x * kSin19 + cen_y * kCos19; + + // Step 5: Scale to substrate (sqrt(7) finer) and re-quantize + quantize_rotation_classI(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); +} + +/** + * Quantize to Rotation Class III-B (~49-degree) hexagon (aperture-7, odd resolutions). + * + * Surrogate: Rotation Class II grid rotated by -19.1 degrees + * (equivalent to Rotation Class I grid rotated by -49.1 degrees) + * Substrate: sqrt(21) times finer than surrogate + */ +inline void quantize_rotation_classIII_B(double x, double y, + long long& out_i, long long& out_j) { + using namespace detail; + + // Step 1: Rotate to surrogate frame (-19.1 degrees) + double sur_x = x * kCos19 + y * kSin19; + double sur_y = -x * kSin19 + y * kCos19; + + // Step 2: The surrogate is Rotation Class II (rotated 30 deg from Rotation Class I). + // To quantize, first rotate to Rotation Class I orientation. + constexpr double cos_neg30 = kCos30; + constexpr double sin_neg30 = -0.5; + + double rotated_x = sur_x * cos_neg30 - sur_y * sin_neg30; + double rotated_y = sur_x * sin_neg30 + sur_y * cos_neg30; + + // Step 3: Quantize in Rotation Class I grid + long long sur_i, sur_j; + quantize_rotation_classI(rotated_x, rotated_y, sur_i, sur_j); + + // Step 4: Get Rotation Class I center + double cen_x, cen_y; + center_rotation_classI(sur_i, sur_j, cen_x, cen_y); + + // Step 5: Rotate back to Rotation Class II orientation (+30 degrees) + double rotated_back_x = cen_x * cos_neg30 + cen_y * sin_neg30; + double rotated_back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; + + // Step 6: Rotate back to original frame (+19.1 degrees) + double back_x = rotated_back_x * kCos19 - rotated_back_y * kSin19; + double back_y = rotated_back_x * kSin19 + rotated_back_y * kCos19; + + // Step 7: Scale to substrate (sqrt(21) finer) and re-quantize + quantize_rotation_classI(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); +} + +// ============================================================================ +// Hexagon Corner Generation +// ============================================================================ + +/** + * Generate the 6 corners of a hexagon given its center and radius. + * + * @param cx Center X coordinate + * @param cy Center Y coordinate + * @param radius Distance from center to each corner + * @param rotation_deg Rotation offset in degrees (0 = flat-top, 30 = pointy-top) + * @param out_x Output array of 6 X coordinates + * @param out_y Output array of 6 Y coordinates + */ +inline void generate_hex_corners(double cx, double cy, double radius, + double rotation_deg, + double* out_x, double* out_y) { + double rotation_rad = rotation_deg * kDegToRad; + + // Generate 6 vertices starting at top, counter-clockwise + for (int k = 0; k < 6; ++k) { + double angle = kPiOver2 + rotation_rad + k * kPiOver3; + out_x[k] = cx + radius * std::cos(angle); + out_y[k] = cy + radius * std::sin(angle); + } +} + +// ============================================================================ +// Aperture Scale Factors +// ============================================================================ + +/** + * Get the cumulative scale factor for a given aperture and resolution. + * + * @param aperture Aperture type (3, 4, or 7) + * @param resolution Grid resolution level + * @return Scale factor to multiply coordinates by + */ +inline double aperture_scale(int aperture, int resolution) { + switch (aperture) { + case 3: return std::pow(kSqrt3, resolution); + case 4: return std::pow(2.0, resolution); + case 7: return std::pow(kSqrt7, resolution); + default: return 1.0; + } +} + +/** + * Get the substrate scale multiplier for the current orientation class. + * + * @param aperture Aperture type + * @param resolution Grid resolution level + * @return Additional scale factor for substrate coordinates + */ +inline double substrate_multiplier(int aperture, int resolution) { + bool is_even = (resolution % 2 == 0); + + switch (aperture) { + case 3: + // Rotation Class I (even): no extra scale + // Rotation Class II (odd): sqrt(3) substrate + return is_even ? 1.0 : kSqrt3; + case 4: + // Always Rotation Class I, no extra scale + return 1.0; + case 7: + // Rotation Class III-A (even): sqrt(7) substrate + // Rotation Class III-B (odd): sqrt(21) substrate + return is_even ? kSqrt7 : kSqrt21; + default: + return 1.0; + } +} + +/** + * Get the rotation offset in degrees for hexagon corners. + * + * @param aperture Aperture type + * @param resolution Grid resolution level + * @return Rotation offset in degrees + */ +inline double corner_rotation_deg(int aperture, int resolution) { + bool is_even = (resolution % 2 == 0); + + switch (aperture) { + case 3: + // Rotation Class I: 0 deg, Rotation Class II: 30 deg + return is_even ? 0.0 : 30.0; + case 4: + // Always Rotation Class I + return 0.0; + case 7: + // Rotation Class III-A: ~19.1 deg, Rotation Class III-B: ~49.1 deg + return is_even ? kAp7RotDeg : (kAp7RotDeg + 30.0); + default: + return 0.0; + } +} + +} // namespace hexify + +#endif // HEXIFY_GRID_MATH_H From d90e9ec60c29e3d7443a4010e61a23e97ee2dc8e Mon Sep 17 00:00:00 2001 From: Gilles Colling Date: Thu, 13 Aug 2026 15:56:04 +0200 Subject: [PATCH 4/4] fix: normalize line endings to LF in previously edited files Previous commit accidentally wrote CRLF line endings into these three files, inconsistent with the rest of the repo, which inflated the diff to a full-file rewrite. No content change beyond line endings. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Cmiqcjs9Ev27KLkdgHmPkV --- src/constants.h | 360 ++--- src/coordinate_transforms.cpp | 2698 ++++++++++++++++----------------- src/grid_math.h | 858 +++++------ 3 files changed, 1958 insertions(+), 1958 deletions(-) diff --git a/src/constants.h b/src/constants.h index fb4e3af..45932ba 100644 --- a/src/constants.h +++ b/src/constants.h @@ -1,180 +1,180 @@ -// constants.h - Shared mathematical constants for hexify -// -// All constants computed to full IEEE 754 double precision (15-17 significant digits). -// Values verified against Wolfram Alpha / mpfr where applicable. -// -// Copyright (c) 2024 hexify authors. MIT License. - -#ifndef HEXIFY_CONSTANTS_H -#define HEXIFY_CONSTANTS_H - -namespace hexify { - -// ============================================================================= -// Fundamental Mathematical Constants -// ============================================================================= - -// Pi and multiples (full double precision) -constexpr double kPi = 3.141592653589793238462643383279502884; -constexpr double kTwoPi = 6.283185307179586476925286766559005768; -constexpr double kPiOver2 = 1.570796326794896619231321691639751442; -constexpr double kPiOver3 = 1.047197551196597746154214461093167628; -constexpr double kPiOver6 = 0.523598775598298873077107230546583814; - -// ============================================================================= -// Square Roots -// ============================================================================= - -constexpr double kSqrt3 = 1.732050807568877293527446341505872367; -constexpr double kSqrt7 = 2.645751311064590590501615753639260426; -constexpr double kSqrt21 = 4.582575694955840006588047193728008489; // sqrt(3 * 7) - -// ============================================================================= -// Trigonometric Values -// ============================================================================= - -constexpr double kSin60 = 0.866025403784438646763723170752936183; // sqrt(3)/2 -constexpr double kCos60 = 0.5; -constexpr double kSin30 = 0.5; -constexpr double kCos30 = 0.866025403784438646763723170752936183; // sqrt(3)/2 - -// ============================================================================= -// Degree/Radian Conversion -// ============================================================================= - -constexpr double kDegToRad = 0.017453292519943295769236907684886127; // pi/180 -constexpr double kRadToDeg = 57.29577951308232087679815481410517033; // 180/pi - -// ============================================================================= -// ISEA Projection Constants -// ============================================================================= - -// Aperture 7 rotation angle: arctan(sqrt(3)/5) in degrees -// Exact: atan(sqrt(3)/5) = 19.10660535086909...° -// Cross-checked against DGGRID's M_AP7_ROT_DEGS (src/lib/dglib/include/dglib/DgConstants.h) -constexpr double kAp7RotDeg = 19.106605350869094394517474740130082234976075229; - -// ============================================================================= -// Snyder Projection Sector Angles -// ============================================================================= - -// Triangle sector boundaries (radians) - used for azimuth reduction -constexpr double k2PiOver3 = 2.094395102393195492308428922186335256; // 120° = 2π/3 -constexpr double k4PiOver3 = 4.188790204786390984616857844372670512; // 240° = 4π/3 - -// ============================================================================= -// Snyder ISEA Projection Constants (from Snyder 1992) -// ============================================================================= -// Reference: Snyder, J.P. (1992). "An Equal-Area Map Projection for Polyhedral Globes" -// Cartographica 29(1): 10-21. - -// R1: Scale factor for equal-area property (Snyder notation: R') -constexpr double kSnyderR1 = 0.9103832815; -constexpr double kSnyderR1Squared = kSnyderR1 * kSnyderR1; - -// SNYDER_EL_ANGLE: θ - spherical angle from face center to edge midpoint (37.377...°) -// This is the "el" angle in Snyder's notation -constexpr double kSnyderElAngleDeg = 37.37736814; -constexpr double kSnyderElAngle = kSnyderElAngleDeg * kDegToRad; - -// SNYDER_G_ANGLE: G - angle at icosahedron vertices (36° exactly) -constexpr double kSnyderGAngleDeg = 36.0; -constexpr double kSnyderGAngle = kSnyderGAngleDeg * kDegToRad; - -// Face-plane origin offsets (for normalizing projected coordinates) -constexpr double kSnyderOriginXOff = 0.6022955029; -constexpr double kSnyderOriginYOff = 0.3477354707; -constexpr double kSnyderIcosaEdge = 2.0 * kSnyderOriginXOff; - -// ============================================================================= -// PLANE Coordinate Layout Table (Icosahedron Unfolding) -// ============================================================================= -// Each triangle has a rotation (in 60° increments) and an offset position -// in the unfolded PLANE coordinate system. Standard ISEA icosahedron layout. - -// Structure to hold triangle transformation parameters -struct PlaneTriLayout { - int rot60; // Rotation in 60° increments (0, 3, etc.) - double offset_x; // X offset in PLANE coordinates - double offset_y; // Y offset in PLANE coordinates -}; - -// M_SIN60 = sqrt(3)/2 ≈ 0.866025... -// The layout creates a 5.5 × ~1.73 unit rectangle containing all 20 triangles - -constexpr PlaneTriLayout kPlaneLayout[20] = { - // Upper row (faces 0-4): rot60=0, y = 2*sin60 - {0, 0.0, 2.0 * kSin60}, // face 0 - {0, 1.0, 2.0 * kSin60}, // face 1 - {0, 2.0, 2.0 * kSin60}, // face 2 - {0, 3.0, 2.0 * kSin60}, // face 3 - {0, 4.0, 2.0 * kSin60}, // face 4 - // Upper row continued (faces 5-9): rot60=3, y = 2*sin60 - {3, 1.0, 2.0 * kSin60}, // face 5 - {3, 2.0, 2.0 * kSin60}, // face 6 - {3, 3.0, 2.0 * kSin60}, // face 7 - {3, 4.0, 2.0 * kSin60}, // face 8 - {3, 5.0, 2.0 * kSin60}, // face 9 - // Lower row (faces 10-14): rot60=0, y = sin60 - {0, 0.5, kSin60}, // face 10 - {0, 1.5, kSin60}, // face 11 - {0, 2.5, kSin60}, // face 12 - {0, 3.5, kSin60}, // face 13 - {0, 4.5, kSin60}, // face 14 - // Lower row continued (faces 15-19): rot60=3, y = sin60 - {3, 1.5, kSin60}, // face 15 - {3, 2.5, kSin60}, // face 16 - {3, 3.5, kSin60}, // face 17 - {3, 4.5, kSin60}, // face 18 - {3, 5.5, kSin60} // face 19 -}; - -// ============================================================================= -// Grid Bounds -// ============================================================================= -// Mirrors R/constants.R's MIN_RESOLUTION/MAX_RESOLUTION. Resolutions outside -// this range are rejected before they can reach shift-overflow or -// scale-to-infinity arithmetic in the grid-dimension calculations. - -constexpr int kMinResolution = 0; -constexpr int kMaxResolution = 30; - -// Valid range for a quad index (12 quads: 0 = north pole, 1-10 = equatorial -// belt, 11 = south pole) -constexpr int kMinQuad = 0; -constexpr int kMaxQuad = 11; - -// Valid range for an icosahedron triangle face index -constexpr int kMinFace = 0; -constexpr int kMaxFace = 19; - -// ============================================================================= -// Numerical Precision Constants -// ============================================================================= - -// Minimum denominator value to prevent division by zero in floating-point math -constexpr double kMinDenom = 1e-18; - -// Epsilon for branching decisions (very small values treated as zero) -constexpr double kEpsBranch = 1e-15; - -// ============================================================================= -// Inline Utility Functions -// ============================================================================= - -/** - * Returns a non-zero denominator suitable for division. - * If abs(x) < kMinDenom, returns kMinDenom with the original sign (or positive if x=0). - * This prevents division-by-zero while preserving the sign of near-zero values. - */ -inline double safe_denom(double x) noexcept { - if (x >= 0.0) { - return (x < kMinDenom) ? kMinDenom : x; - } else { - return (x > -kMinDenom) ? -kMinDenom : x; - } -} - -} // namespace hexify - -#endif // HEXIFY_CONSTANTS_H +// constants.h - Shared mathematical constants for hexify +// +// All constants computed to full IEEE 754 double precision (15-17 significant digits). +// Values verified against Wolfram Alpha / mpfr where applicable. +// +// Copyright (c) 2024 hexify authors. MIT License. + +#ifndef HEXIFY_CONSTANTS_H +#define HEXIFY_CONSTANTS_H + +namespace hexify { + +// ============================================================================= +// Fundamental Mathematical Constants +// ============================================================================= + +// Pi and multiples (full double precision) +constexpr double kPi = 3.141592653589793238462643383279502884; +constexpr double kTwoPi = 6.283185307179586476925286766559005768; +constexpr double kPiOver2 = 1.570796326794896619231321691639751442; +constexpr double kPiOver3 = 1.047197551196597746154214461093167628; +constexpr double kPiOver6 = 0.523598775598298873077107230546583814; + +// ============================================================================= +// Square Roots +// ============================================================================= + +constexpr double kSqrt3 = 1.732050807568877293527446341505872367; +constexpr double kSqrt7 = 2.645751311064590590501615753639260426; +constexpr double kSqrt21 = 4.582575694955840006588047193728008489; // sqrt(3 * 7) + +// ============================================================================= +// Trigonometric Values +// ============================================================================= + +constexpr double kSin60 = 0.866025403784438646763723170752936183; // sqrt(3)/2 +constexpr double kCos60 = 0.5; +constexpr double kSin30 = 0.5; +constexpr double kCos30 = 0.866025403784438646763723170752936183; // sqrt(3)/2 + +// ============================================================================= +// Degree/Radian Conversion +// ============================================================================= + +constexpr double kDegToRad = 0.017453292519943295769236907684886127; // pi/180 +constexpr double kRadToDeg = 57.29577951308232087679815481410517033; // 180/pi + +// ============================================================================= +// ISEA Projection Constants +// ============================================================================= + +// Aperture 7 rotation angle: arctan(sqrt(3)/5) in degrees +// Exact: atan(sqrt(3)/5) = 19.10660535086909...° +// Cross-checked against DGGRID's M_AP7_ROT_DEGS (src/lib/dglib/include/dglib/DgConstants.h) +constexpr double kAp7RotDeg = 19.106605350869094394517474740130082234976075229; + +// ============================================================================= +// Snyder Projection Sector Angles +// ============================================================================= + +// Triangle sector boundaries (radians) - used for azimuth reduction +constexpr double k2PiOver3 = 2.094395102393195492308428922186335256; // 120° = 2π/3 +constexpr double k4PiOver3 = 4.188790204786390984616857844372670512; // 240° = 4π/3 + +// ============================================================================= +// Snyder ISEA Projection Constants (from Snyder 1992) +// ============================================================================= +// Reference: Snyder, J.P. (1992). "An Equal-Area Map Projection for Polyhedral Globes" +// Cartographica 29(1): 10-21. + +// R1: Scale factor for equal-area property (Snyder notation: R') +constexpr double kSnyderR1 = 0.9103832815; +constexpr double kSnyderR1Squared = kSnyderR1 * kSnyderR1; + +// SNYDER_EL_ANGLE: θ - spherical angle from face center to edge midpoint (37.377...°) +// This is the "el" angle in Snyder's notation +constexpr double kSnyderElAngleDeg = 37.37736814; +constexpr double kSnyderElAngle = kSnyderElAngleDeg * kDegToRad; + +// SNYDER_G_ANGLE: G - angle at icosahedron vertices (36° exactly) +constexpr double kSnyderGAngleDeg = 36.0; +constexpr double kSnyderGAngle = kSnyderGAngleDeg * kDegToRad; + +// Face-plane origin offsets (for normalizing projected coordinates) +constexpr double kSnyderOriginXOff = 0.6022955029; +constexpr double kSnyderOriginYOff = 0.3477354707; +constexpr double kSnyderIcosaEdge = 2.0 * kSnyderOriginXOff; + +// ============================================================================= +// PLANE Coordinate Layout Table (Icosahedron Unfolding) +// ============================================================================= +// Each triangle has a rotation (in 60° increments) and an offset position +// in the unfolded PLANE coordinate system. Standard ISEA icosahedron layout. + +// Structure to hold triangle transformation parameters +struct PlaneTriLayout { + int rot60; // Rotation in 60° increments (0, 3, etc.) + double offset_x; // X offset in PLANE coordinates + double offset_y; // Y offset in PLANE coordinates +}; + +// M_SIN60 = sqrt(3)/2 ≈ 0.866025... +// The layout creates a 5.5 × ~1.73 unit rectangle containing all 20 triangles + +constexpr PlaneTriLayout kPlaneLayout[20] = { + // Upper row (faces 0-4): rot60=0, y = 2*sin60 + {0, 0.0, 2.0 * kSin60}, // face 0 + {0, 1.0, 2.0 * kSin60}, // face 1 + {0, 2.0, 2.0 * kSin60}, // face 2 + {0, 3.0, 2.0 * kSin60}, // face 3 + {0, 4.0, 2.0 * kSin60}, // face 4 + // Upper row continued (faces 5-9): rot60=3, y = 2*sin60 + {3, 1.0, 2.0 * kSin60}, // face 5 + {3, 2.0, 2.0 * kSin60}, // face 6 + {3, 3.0, 2.0 * kSin60}, // face 7 + {3, 4.0, 2.0 * kSin60}, // face 8 + {3, 5.0, 2.0 * kSin60}, // face 9 + // Lower row (faces 10-14): rot60=0, y = sin60 + {0, 0.5, kSin60}, // face 10 + {0, 1.5, kSin60}, // face 11 + {0, 2.5, kSin60}, // face 12 + {0, 3.5, kSin60}, // face 13 + {0, 4.5, kSin60}, // face 14 + // Lower row continued (faces 15-19): rot60=3, y = sin60 + {3, 1.5, kSin60}, // face 15 + {3, 2.5, kSin60}, // face 16 + {3, 3.5, kSin60}, // face 17 + {3, 4.5, kSin60}, // face 18 + {3, 5.5, kSin60} // face 19 +}; + +// ============================================================================= +// Grid Bounds +// ============================================================================= +// Mirrors R/constants.R's MIN_RESOLUTION/MAX_RESOLUTION. Resolutions outside +// this range are rejected before they can reach shift-overflow or +// scale-to-infinity arithmetic in the grid-dimension calculations. + +constexpr int kMinResolution = 0; +constexpr int kMaxResolution = 30; + +// Valid range for a quad index (12 quads: 0 = north pole, 1-10 = equatorial +// belt, 11 = south pole) +constexpr int kMinQuad = 0; +constexpr int kMaxQuad = 11; + +// Valid range for an icosahedron triangle face index +constexpr int kMinFace = 0; +constexpr int kMaxFace = 19; + +// ============================================================================= +// Numerical Precision Constants +// ============================================================================= + +// Minimum denominator value to prevent division by zero in floating-point math +constexpr double kMinDenom = 1e-18; + +// Epsilon for branching decisions (very small values treated as zero) +constexpr double kEpsBranch = 1e-15; + +// ============================================================================= +// Inline Utility Functions +// ============================================================================= + +/** + * Returns a non-zero denominator suitable for division. + * If abs(x) < kMinDenom, returns kMinDenom with the original sign (or positive if x=0). + * This prevents division-by-zero while preserving the sign of near-zero values. + */ +inline double safe_denom(double x) noexcept { + if (x >= 0.0) { + return (x < kMinDenom) ? kMinDenom : x; + } else { + return (x > -kMinDenom) ? -kMinDenom : x; + } +} + +} // namespace hexify + +#endif // HEXIFY_CONSTANTS_H diff --git a/src/coordinate_transforms.cpp b/src/coordinate_transforms.cpp index 0c75adb..08a12f8 100644 --- a/src/coordinate_transforms.cpp +++ b/src/coordinate_transforms.cpp @@ -1,1349 +1,1349 @@ -// coordinate_transforms.cpp - Convert between ISEA DGGS coordinate systems -// -// ============================================================================ -// COORDINATE TRANSFORMATION FLOW -// ============================================================================ -// -// This file implements the coordinate transformations between three systems: -// -// +---------------------+ +---------------+ +-----------+ -// | Icosa Triangle | --> | Quad XY | --> | Quad IJ | -// | (icosa_triangle_ | | (quad, | | (quad, | -// | face, _x, _y) | | quad_x, | | i, j) | -// +---------------------+ | quad_y) | +-----------+ -// | +---------------+ | -// v | v -// Icosahedral face Quad continuous Quad integer -// coordinates coordinates cell indices -// -// Icosa Triangle: Output from Snyder forward projection -// - icosa_triangle_face: Triangle index (0-19) -// - icosa_triangle_x, icosa_triangle_y: Normalized coords within triangle [0,1] -// -// Quad XY: Quad with continuous (double) coordinates -// - quad: Quad index (0-11, where 0=North pole, 11=South pole) -// - quad_x, quad_y: Continuous position within quad -// -// Quad IJ: Quad with integer cell indices (used for cell ID computation) -// - quad: Same as Quad XY -// - i, j: Integer cell coordinates (resolution-dependent) -// -// ============================================================================ -// ICOSAHEDRON GEOMETRY -// ============================================================================ -// -// The icosahedron has 20 triangular faces grouped into 12 quads: -// -// Quad 0 (North Pole) -// /\ -// / \ -// +----+----+----+----+----+ -// | Q1 | Q2 | Q3 | Q4 | Q5 | <- Upper hemisphere (quads 1-5) -// +----+----+----+----+----+ -// | Q6 | Q7 | Q8 | Q9 |Q10 | <- Lower hemisphere (quads 6-10) -// +----+----+----+----+----+ -// \ / -// \/ -// Quad 11 (South Pole) -// -// Each non-polar quad contains 2 triangles forming a rhombus. -// Triangles 0-4 and 5-9 map to quads 1-5 -// Triangles 10-14 and 15-19 map to quads 6-10 -// -// ============================================================================ -// QUANTIZATION CLASSES -// ============================================================================ -// -// Different apertures use different quantization schemes: -// -// Aperture 3: -// - Even resolutions: Class I (aligned hexagons) -// - Odd resolutions: Class II (rotated hexagons) -// -// Aperture 4: -// - All resolutions: Class I (aligned hexagons) -// -// Aperture 7: -// - Even resolutions: Class III-I -// - Odd resolutions: Class III-II -// -// Mathematical foundation from Sahr et al. publications on ISEA grids. -// -// Copyright (c) 2024 hexify authors. MIT License. - -#include "coordinate_transforms.h" -#include "cube_coordinates.h" -#include "ijk_coordinates.h" -#include "index_z7.h" -#include "constants.h" -#include -#include -#include - -namespace hexify { - -namespace { - -// ============================================================================ -// Triangle to Quad Mapping -// ============================================================================ -// -// Icosahedron face layout (20 triangles -> 12 quads): -// -// North Pole (Quad 0) at top, South Pole (Quad 11) at bottom. -// Quads 1-5: upper hemisphere, Quads 6-10: lower hemisphere. -// Each quad contains 2 triangles forming a rhombus shape. -// -// Each non-polar quad contains 2 triangles. The mapping specifies: -// - Which quad a triangle belongs to -// - Rotation and translation to align triangle coords with quad coords - -struct TriangleMapping { - int quad; // Target quad (1-10 for regular quads) - int sub_triangle; // 0 = primary, 1 = secondary (rotated/translated) - double offset_x; // X offset after rotation - double offset_y; // Y offset after rotation - int rotations; // Number of 60° clockwise rotations -}; - -// Mapping table derived from ISEA icosahedron geometry -// Triangle indices 0-19 map to quads 1-10 (polar quads 0,11 handled separately) -const TriangleMapping kTriangleMap[20] = { - // Upper cap triangles (0-4) -> quads 1-5, primary position - {1, 0, 0.0, 0.0, 1}, - {2, 0, 0.0, 0.0, 1}, - {3, 0, 0.0, 0.0, 1}, - {4, 0, 0.0, 0.0, 1}, - {5, 0, 0.0, 0.0, 1}, - // Upper-middle triangles (5-9) -> quads 1-5, secondary position - {1, 1, -0.5, -kSin60, 4}, - {2, 1, -0.5, -kSin60, 4}, - {3, 1, -0.5, -kSin60, 4}, - {4, 1, -0.5, -kSin60, 4}, - {5, 1, -0.5, -kSin60, 4}, - // Lower-middle triangles (10-14) -> quads 6-10, primary position - {6, 0, 0.0, 0.0, 1}, - {7, 0, 0.0, 0.0, 1}, - {8, 0, 0.0, 0.0, 1}, - {9, 0, 0.0, 0.0, 1}, - {10, 0, 0.0, 0.0, 1}, - // Lower cap triangles (15-19) -> quads 6-10, secondary position - {6, 1, -0.5, -kSin60, 4}, - {7, 1, -0.5, -kSin60, 4}, - {8, 1, -0.5, -kSin60, 4}, - {9, 1, -0.5, -kSin60, 4}, - {10, 1, -0.5, -kSin60, 4}, -}; - -// ============================================================================ -// Rotation Helper -// ============================================================================ - -void rotate_60deg_ccw(double& x, double& y, int n_rotations) { - // Each 60° counter-clockwise rotation: [cos(60) -sin(60); sin(60) cos(60)] - // cos(60°) = 0.5, sin(60°) = sqrt(3)/2 - constexpr double c60 = 0.5; - constexpr double s60 = kSin60; - - n_rotations = ((n_rotations % 6) + 6) % 6; // Normalize to 0-5 - - for (int i = 0; i < n_rotations; ++i) { - double nx = c60 * x - s60 * y; // counter-clockwise: x*cos - y*sin - double ny = s60 * x + c60 * y; // counter-clockwise: x*sin + y*cos - x = nx; - y = ny; - } -} - -// ============================================================================ -// Hex Quantization - Precise hexagonal grid rounding -// ============================================================================ -// This implementation handles all edge cases at hexagon boundaries correctly -// using a decision-tree approach that carefully handles the fractional parts -// of the continuous coordinates. This is more robust than simple cube-coordinate -// rounding at cell boundaries. - -// ============================================================================ -// Boundary Classification for Hex Quantization -// ============================================================================ -// -// The unit cell is divided into 6 regions based on fractional coordinates (frac_i, frac_j). -// Each region determines the (delta_i, delta_j) offset from the base cell (floor_i, floor_j). -// -// The regions form a hexagonal Voronoi partition: -// - Region A: frac_i < 1/3, frac_j < (1+frac_i)/2 -> (0, 0) -// - Region B: frac_i < 1/3, frac_j >= (1+frac_i)/2 -> (0, 1) -// - Region C: 1/3 <= frac_i < 1/2 -> complex boundary (see below) -// - Region D: 1/2 <= frac_i < 2/3 -> complex boundary (see below) -// - Region E: frac_i >= 2/3, frac_j < frac_i/2 -> (1, 0) -// - Region F: frac_i >= 2/3, frac_j >= frac_i/2 -> (1, 1) -// -// For regions C and D, the i-offset depends on whether frac_j falls in the -// "middle band" between two linear thresholds. - -// Classify which boundary region based on fractional coords -// Returns: 0=A, 1=B, 2=C_lower, 3=C_upper, 4=C_mid, 5=D_lower, 6=D_upper, 7=D_mid, 8=E, 9=F -inline int classify_hex_boundary(double frac_i, double frac_j) { - if (frac_i < 1.0/3.0) { - return (frac_j < (1.0 + frac_i) / 2.0) ? 0 : 1; // A or B - } - if (frac_i < 0.5) { - // Region C: thresholds at (1-frac_i) and (2*frac_i) - double lower_threshold = 1.0 - frac_i; - double upper_threshold = 2.0 * frac_i; - if (frac_j < lower_threshold) return 2; // C_lower: j=floor_j - if (frac_j >= upper_threshold) return 3; // C_upper: j=floor_j+1 - return 4; // C_mid: i=floor_i+1 - } - if (frac_i < 2.0/3.0) { - // Region D: thresholds at (2*frac_i-1) and (1-frac_i) - double lower_threshold = 2.0 * frac_i - 1.0; - double upper_threshold = 1.0 - frac_i; - if (frac_j <= lower_threshold) return 5; // D_lower: j=floor_j, i=floor_i+1 - if (frac_j >= upper_threshold) return 6; // D_upper: j=floor_j+1, i=floor_i+1 - return 7; // D_mid: i=floor_i - } - return (frac_j < frac_i / 2.0) ? 8 : 9; // E or F -} - -// Lookup table: boundary_region -> (delta_i, delta_j) offset -// Indexed by classify_hex_boundary() return value -static const int kBoundaryOffset[10][2] = { - {0, 0}, // 0: Region A - {0, 1}, // 1: Region B - {0, 0}, // 2: Region C_lower (j=floor_j) - {0, 1}, // 3: Region C_upper (j=floor_j+1) - {1, 0}, // 4: Region C_mid - special: j depends on frac_j < (1-frac_i) - {1, 0}, // 5: Region D_lower (j=floor_j) - {1, 1}, // 6: Region D_upper (j=floor_j+1) - {0, 0}, // 7: Region D_mid - special: j depends on frac_j < (1-frac_i) - {1, 0}, // 8: Region E - {1, 1}, // 9: Region F -}; - -// Fold i-coordinate across x-axis when x was negative -inline long long fold_i_negative_x(long long i, long long j) { - if ((j % 2) == 0) { - long long axis = j / 2; - return i - 2 * (i - axis); - } else { - long long axis = (j + 1) / 2; - return i - (2 * (i - axis) + 1); - } -} - -// Class I (flat-top) quantization -void quantize_class1(double x, double y, long long& out_i, long long& out_j) { - // Guard against NaN/Inf inputs to avoid undefined behavior in integer cast - if (!std::isfinite(x) || !std::isfinite(y)) { - out_i = 0; - out_j = 0; - return; - } - - // Work in positive quadrant - double abs_x = std::fabs(x); - double abs_y = std::fabs(y); - - // Convert to fractional hex indices - double idx_j = abs_y / kSin60; - double idx_i = abs_x + idx_j / 2.0; - - // Integer (floor) and fractional parts - long long floor_i = static_cast(idx_i); - long long floor_j = static_cast(idx_j); - double frac_i = idx_i - floor_i; - double frac_j = idx_j - floor_j; - - // Classify and look up base offset - int region = classify_hex_boundary(frac_i, frac_j); - long long delta_i = kBoundaryOffset[region][0]; - long long delta_j = kBoundaryOffset[region][1]; - - // Handle special cases where j depends on secondary threshold - if (region == 4) { // C_mid - delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; - } else if (region == 7) { // D_mid - delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; - } - - long long i_result = floor_i + delta_i; - long long j_result = floor_j + delta_j; - - // Fold back to original quadrant - if (x < 0.0) { - i_result = fold_i_negative_x(i_result, j_result); - } - if (y < 0.0) { - i_result = i_result - (2 * j_result + 1) / 2; - j_result = -j_result; - } - - out_i = i_result; - out_j = j_result; -} - -// Class I inverse: (i,j) to (x,y) -void inv_quantize_class1(long long i, long long j, double& x, double& y) { - cube_to_cartesian(static_cast(i), static_cast(j), x, y, kSin60); -} - -// Class II (pointy-top / 30° rotated) quantization -void quantize_class2(double x, double y, long long& out_i, long long& out_j) { - constexpr double angle = -kPi / 6.0; // -30° - double c = std::cos(angle); - double s = std::sin(angle); - - // Rotate to surrogate Class I orientation - double rx = x * c - y * s; - double ry = x * s + y * c; - - // Quantize in surrogate - long long sur_i, sur_j; - quantize_class1(rx, ry, sur_i, sur_j); - - // Get surrogate center and rotate back - double sur_x, sur_y; - inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); - - double back_x = sur_x * c + sur_y * s; // Rotate +30° - double back_y = -sur_x * s + sur_y * c; - - // Scale to substrate and re-quantize - quantize_class1(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); -} - - -// ============================================================================ -// Class III Quantization (Aperture 7) -// ============================================================================ -// Class III hexagons are rotated by arctan(sqrt(3)/5) ~= 19.1deg from Class I. -// This creates a grid where only 1/7 of substrate cells are valid. - -// Aperture 7 rotation angle in radians -constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; - -// Class III-I (even resolutions): Class I surrogate rotated by ~19.1deg -void quantize_class3i(double x, double y, long long& out_i, long long& out_j) { - const double c = std::cos(-kAp7RotRad); - const double s = std::sin(-kAp7RotRad); - - // Rotate to surrogate - double rx = x * c - y * s; - double ry = x * s + y * c; - - // Quantize in Class I surrogate - long long sur_i, sur_j; - quantize_class1(rx, ry, sur_i, sur_j); - - // Get surrogate center and rotate back - double sur_x, sur_y; - inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); - - double back_x = sur_x * c + sur_y * s; - double back_y = -sur_x * s + sur_y * c; - - // Scale to substrate (sqrt(7)x finer) and re-quantize - quantize_class1(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); -} - -// Class III-II (odd resolutions): Class II surrogate rotated by ~19.1deg -// The surrogate is a Class II grid (pointy-top, 30° rotated from Class I). -// We need to: -// 1. Rotate to surrogate frame (-19.1 degrees) -// 2. Quantize in Class II surrogate (which is Class I at -30 degrees) -// 3. Get the Class II surrogate center -// 4. Rotate back to original frame (+19.1 degrees) -// 5. Scale to Class I substrate (sqrt(21)x) and re-quantize -void quantize_class3ii(double x, double y, long long& out_i, long long& out_j) { - const double c_ap7 = std::cos(-kAp7RotRad); - const double s_ap7 = std::sin(-kAp7RotRad); - - // Step 1: Rotate to surrogate frame (-19.1 degrees) - double sur_x = x * c_ap7 - y * s_ap7; - double sur_y = x * s_ap7 + y * c_ap7; - - // Step 2: Quantize in Class II surrogate - // Class II = Class I rotated by -30 degrees - constexpr double c_30 = 0.866025403784438646763723170752936183; // cos(-30°) - constexpr double s_30 = -0.5; // sin(-30°) - - // Rotate to Class I orientation within the surrogate - double c1_x = sur_x * c_30 - sur_y * s_30; - double c1_y = sur_x * s_30 + sur_y * c_30; - - // Quantize in Class I - long long sur1_i, sur1_j; - quantize_class1(c1_x, c1_y, sur1_i, sur1_j); - - // Get Class I center - double sur1_cen_x, sur1_cen_y; - inv_quantize_class1(sur1_i, sur1_j, sur1_cen_x, sur1_cen_y); - - // Rotate back to Class II orientation (+30 degrees) - double c2_back_x = sur1_cen_x * c_30 + sur1_cen_y * s_30; - double c2_back_y = -sur1_cen_x * s_30 + sur1_cen_y * c_30; - - // Step 3: Rotate back to original frame (+19.1 degrees) - double back_x = c2_back_x * c_ap7 + c2_back_y * s_ap7; - double back_y = -c2_back_x * s_ap7 + c2_back_y * c_ap7; - - // Step 4: Scale to substrate (sqrt(21)x finer for Class III-II) and re-quantize - quantize_class1(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); -} - -// ============================================================================ -// Quad Edge Adjacency -// ============================================================================ -// -// When a cell falls on the edge of a quad, it may belong to an adjacent quad. -// This table defines the adjacency relationships. - -struct QuadAdjacency { - bool is_upper; // Upper hemisphere quad (1-5) vs lower (6-10) - int up_neighbor; // Quad above (for top edge overflow) - int right_neighbor; // Quad to the right (for right edge overflow) -}; - -const QuadAdjacency kQuadAdjacency[12] = { - {true, 0, 0}, // Quad 0: north pole (unused) - {true, 2, 6}, // Quad 1 - {true, 3, 7}, // Quad 2 - {true, 4, 8}, // Quad 3 - {true, 5, 9}, // Quad 4 - {true, 1, 10}, // Quad 5 - {false, 2, 7}, // Quad 6 - {false, 3, 8}, // Quad 7 - {false, 4, 9}, // Quad 8 - {false, 5, 10}, // Quad 9 - {false, 1, 6}, // Quad 10 - {false, 0, 0}, // Quad 11: south pole (unused) -}; - -} // anonymous namespace - -// ============================================================================ -// Aperture 7: Substrate ↔ True Surrogate Conversion -// ============================================================================ -// These functions convert between substrate (Class I) integer coordinates -// and the "true surrogate" coordinates used internally by DGGRID's Class III -// grids. The surrogate is a Class I (even res) or Class II (odd res) hex grid -// rotated by ~19.1° from the substrate frame. -// -// This is needed for finding neighbors: ±1 offsets in surrogate integer space -// correspond to actual adjacent ap7 cells. - -void substrate_to_surrogate_ap7(long long sub_i, long long sub_j, int resolution, - long long& sur_i, long long& sur_j) { - double x, y; - inv_quantize_class1(sub_i, sub_j, x, y); - - // Substrate Cartesian is sqrt(7)x (even) or sqrt(21)x (odd) larger - // than the surrogate scale. Divide down before rotation + quantization. - bool is_class3i = (resolution % 2 == 0); - double divisor = is_class3i ? kSqrt7 : kSqrt21; - x /= divisor; - y /= divisor; - - const double c = std::cos(-kAp7RotRad); - const double s = std::sin(-kAp7RotRad); - double rx = x * c - y * s; - double ry = x * s + y * c; - - if (is_class3i) { - quantize_class1(rx, ry, sur_i, sur_j); - } else { - constexpr double c_30 = 0.866025403784438646763723170752936183; - constexpr double s_30 = -0.5; - double c1x = rx * c_30 - ry * s_30; - double c1y = rx * s_30 + ry * c_30; - quantize_class1(c1x, c1y, sur_i, sur_j); - } -} - -void surrogate_to_substrate_ap7(long long sur_i, long long sur_j, int resolution, - long long& sub_i, long long& sub_j) { - double sx, sy; - inv_quantize_class1(sur_i, sur_j, sx, sy); - - bool is_class3i = (resolution % 2 == 0); - const double c_ap7 = std::cos(kAp7RotRad); - const double s_ap7 = std::sin(kAp7RotRad); - - double back_x, back_y; - if (is_class3i) { - back_x = sx * c_ap7 - sy * s_ap7; - back_y = sx * s_ap7 + sy * c_ap7; - quantize_class1(back_x * kSqrt7, back_y * kSqrt7, sub_i, sub_j); - } else { - constexpr double c_30 = 0.866025403784438646763723170752936183; - constexpr double s_30 = 0.5; - double c2x = sx * c_30 - sy * s_30; - double c2y = sx * s_30 + sy * c_30; - back_x = c2x * c_ap7 - c2y * s_ap7; - back_y = c2x * s_ap7 + c2y * c_ap7; - quantize_class1(back_x * kSqrt21, back_y * kSqrt21, sub_i, sub_j); - } -} - -// ============================================================================ -// Aperture 7: exact-integer surrogate machinery (matches DGGRID / H3) -// ============================================================================ -// The "surrogate" is hexify's canonical aperture-7 cell coordinate: the exact -// integer IJK of the resolution-r cell. It is obtained by a clean, unrotated -// Class I quantization of the shared quad_xy frame at the Class I substrate -// scale (7^numClassI = sqrt(7)^effectiveRes), DGGRID's edgeTable quad -// canonicalization, and, for odd resolutions, one exact aperture-7 coarsen -// (upAp7r). This replaces the earlier floating-point-rotation surrogate, whose -// re-quantization rounded boundary cells to a neighbour and diverged from the -// exact integer grid. - -namespace { - -// DgIDGGBase::edgeTable_[12]: quads 0/11 are pole placeholders (never occur). -struct DgQuadEdge { int quadNum; bool isType0; int loneVert, up, down, right, left; }; -const DgQuadEdge kDggridEdgeTable[12] = { - {0, true, 0, 0, 0, 0, 0}, - {1, true, 0, 2, 10, 6, 5}, - {2, true, 0, 3, 6, 7, 1}, - {3, true, 0, 4, 7, 8, 2}, - {4, true, 0, 5, 8, 9, 3}, - {5, true, 0, 1, 9, 10, 4}, - {6, false, 11, 2, 10, 7, 1}, - {7, false, 11, 3, 6, 8, 2}, - {8, false, 11, 4, 7, 9, 3}, - {9, false, 11, 5, 8, 10, 4}, - {10, false, 11, 1, 9, 6, 5}, - {11, false, 11, 0, 0, 0, 0}, -}; - -// Reassign an out-of-box Class I (i,j) to the quad that owns it. topEdge = -// 7^numClassI = maxI + 1 = maxJ + 1. Port of DgQ2DDtoIConverter's reassignment. -void dggrid_canonicalize_q2di(long long topEdge, int& quadNum, - long long& i, long long& j) { - const long long maxI = topEdge - 1, maxJ = topEdge - 1; - const long long topEdgeI = topEdge, topEdgeJ = topEdge; - - bool underI = i < 0, underJ = j < 0, overI = i > maxI, overJ = j > maxJ; - int numOver = (int)underI + (int)underJ + (int)overI + (int)overJ; - if (!numOver) return; - - const DgQuadEdge& ec = kDggridEdgeTable[quadNum]; - - if (overI && overJ) { - quadNum = ec.isType0 ? ec.up : ec.right; - i = 0; j = 0; - } else if (numOver > 1) { - return; // multi-underage: unreached for valid cell centres - } else if (underI) { - quadNum = ec.left; - if (ec.isType0) { long long ni = topEdgeJ - j + i, nj = topEdgeJ + i; i = ni; j = nj; } - else { i = topEdgeI + i; } - } else if (underJ) { - quadNum = ec.down; - if (ec.isType0) { j = topEdgeJ + j; } - else { long long ni = topEdgeJ + j, nj = (topEdgeI - i) + j; i = ni; j = nj; } - } else if (overI) { - if (ec.isType0) { quadNum = ec.right; i = i - topEdgeI; } - else if (j == 0) { quadNum = ec.loneVert; i = 0; j = 0; } - else { quadNum = ec.right; long long iOver = i - topEdgeI; long long ni = (topEdgeJ - j) + iOver; i = ni; j = iOver; } - } else if (overJ) { - if (!ec.isType0) { quadNum = ec.up; j = j - topEdgeJ; } - else if (i == 0) { quadNum = ec.loneVert; i = 0; j = 0; } - else { quadNum = ec.up; long long jOver = j - topEdgeJ; long long nj = topEdgeI - i + jOver; i = jOver; j = nj; } - } -} - -} // anonymous namespace - -long long ap7_classI_scale(int resolution) { // 7^numClassI, numClassI = (res+1)/2 - long long s = 1; - for (int k = 0, n = (resolution + 1) / 2; k < n; ++k) s *= 7; - return s; -} - -void ap7_substrate_to_surrogate_ijk(long long sub_i, long long sub_j, int resolution, - long long& sur_i, long long& sur_j) { - if (resolution % 2 == 0) { sur_i = sub_i; sur_j = sub_j; return; } - z7::IVec3D v(sub_i, sub_j, 0); - v.upAp7r(); - z7::IVec2D a(v); - sur_i = a.i(); - sur_j = a.j(); -} - -void ap7_surrogate_to_substrate_ijk(long long sur_i, long long sur_j, int resolution, - long long& sub_i, long long& sub_j) { - if (resolution % 2 == 0) { sub_i = sur_i; sub_j = sur_j; return; } - z7::IVec3D v(sur_i, sur_j, 0); - v.downAp7r(); - z7::IVec2D a(v); - sub_i = a.i(); - sub_j = a.j(); -} - -void quad_xy_to_surrogate_ij_ap7(double quad_x, double quad_y, int resolution, - long long& sur_i, long long& sur_j) { - long long S = ap7_classI_scale(resolution); - long long sub_i, sub_j; - quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), - sub_i, sub_j); - ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, sur_i, sur_j); -} - -void surrogate_ij_to_quad_xy_ap7(long long sur_i, long long sur_j, int resolution, - double& out_quad_x, double& out_quad_y) { - long long S = ap7_classI_scale(resolution); - long long sub_i, sub_j; - ap7_surrogate_to_substrate_ijk(sur_i, sur_j, resolution, sub_i, sub_j); - double cx, cy; - inv_quantize_class1(sub_i, sub_j, cx, cy); - out_quad_x = cx / static_cast(S); - out_quad_y = cy / static_cast(S); -} - -// ============================================================================ -// Public API Implementation -// ============================================================================ - -void icosa_tri_to_quad_xy(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, - int& out_quad, double& out_quad_x, double& out_quad_y) { - if (icosa_triangle_face < 0 || icosa_triangle_face >= 20) { - throw std::runtime_error("icosa_tri_to_quad_xy: icosa_triangle_face must be 0-19"); - } - - const TriangleMapping& mapping = kTriangleMap[icosa_triangle_face]; - - out_quad = mapping.quad; - out_quad_x = icosa_triangle_x; - out_quad_y = icosa_triangle_y; - - // Apply rotation then translation - rotate_60deg_ccw(out_quad_x, out_quad_y, mapping.rotations); - out_quad_x -= mapping.offset_x; - out_quad_y -= mapping.offset_y; -} - -long long get_max_ij(int aperture, int resolution) { - if (resolution <= 0) return 0; - - double factor; - if (aperture == 3) { - factor = std::pow(kSqrt3, resolution); - // Class II (odd res) uses finer substrate - if (resolution % 2 != 0) { - factor *= kSqrt3; - } - } else if (aperture == 4) { - factor = std::pow(2.0, resolution); - } else if (aperture == 7) { - factor = std::pow(std::sqrt(7.0), resolution); - // Class III-I (even res) uses sqrt(7) substrate, Class III-II (odd res) uses sqrt(21) - bool is_class3i = (resolution % 2 == 0); - factor *= is_class3i ? kSqrt7 : kSqrt21; - } else { - return 0; - } - - return static_cast(factor + 1e-9) - 1; -} - -// Handle edge overflow for upper hemisphere quads (1-5) -// Returns true if overflow was handled -inline bool handle_upper_edge(int& quad, long long& i, long long& j, - long long edge_coord, const QuadAdjacency& adj) { - if (j == edge_coord) { - // Top edge - if (i == 0) { - quad = 0; // North pole - i = j = 0; - } else { - quad = adj.up_neighbor; - long long new_j = edge_coord - i; - i = 0; - j = new_j; - } - return true; - } - if (i == edge_coord) { - // Right edge -> right neighbor - quad = adj.right_neighbor; - i = 0; - return true; - } - return false; -} - -// Handle edge overflow for lower hemisphere quads (6-10) -// Returns true if overflow was handled -inline bool handle_lower_edge(int& quad, long long& i, long long& j, - long long edge_coord, const QuadAdjacency& adj) { - if (i == edge_coord) { - // Right edge - if (j == 0) { - quad = 11; // South pole - i = j = 0; - } else { - quad = adj.right_neighbor; - long long new_i = edge_coord - j; - i = new_i; - j = 0; - } - return true; - } - if (j == edge_coord) { - // Top edge -> up neighbor - quad = adj.up_neighbor; - j = 0; - return true; - } - return false; -} - -bool handle_edge_overflow(int& quad, long long& i, long long& j, - int aperture, int resolution) { - long long edge_coord = get_max_ij(aperture, resolution) + 1; - - // Quick exit: not on edge - if (i != edge_coord && j != edge_coord) return false; - - // Polar quads don't overflow - if (quad < 1 || quad > 10) return false; - - const QuadAdjacency& adj = kQuadAdjacency[quad]; - - return adj.is_upper - ? handle_upper_edge(quad, i, j, edge_coord, adj) - : handle_lower_edge(quad, i, j, edge_coord, adj); -} - -void quad_xy_to_ij(int quad, double quad_x, double quad_y, - int aperture, int resolution, - int& out_quad, long long& out_i, long long& out_j) { - - // Aperture 7: exact-integer route. Clean unrotated Class I quantization at - // the substrate scale, DGGRID edgeTable quad canonicalization (an out-of-box - // coordinate belongs to the neighbouring quad), then (odd res) one exact - // aperture-7 coarsen -- yielding the exact resolution-r cell IJK. This keeps - // forward/inverse geometry consistent and replaces the float-rotation Class - // III quantization + handle_edge_overflow, which rounded boundary cells. - if (aperture == 7) { - if (resolution == 0) { - // Resolution 0: one cell per quad plus the two poles. Poles arise - // from the edge-overflow mapping (a point at an icosa vertex), so - // keep that here rather than the z7 hierarchy (empty at res 0). - quantize_class1(quad_x, quad_y, out_i, out_j); - out_quad = quad; - handle_edge_overflow(out_quad, out_i, out_j, 7, 0); - return; - } - long long S = ap7_classI_scale(resolution); - long long sub_i, sub_j; - quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), - sub_i, sub_j); - out_quad = quad; - dggrid_canonicalize_q2di(S, out_quad, sub_i, sub_j); - ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, out_i, out_j); - return; - } - - // Compute scale factor - double scale; - if (aperture == 3) { - scale = std::pow(kSqrt3, resolution); - } else if (aperture == 4) { - scale = std::pow(2.0, resolution); - } else { - throw std::runtime_error("quad_xy_to_ij: unsupported aperture"); - } - - double scaled_x = quad_x * scale; - double scaled_y = quad_y * scale; - - // Select quantization based on aperture and grid class - if (aperture == 4 || (aperture == 3 && resolution % 2 == 0)) { - // Class I quantization - quantize_class1(scaled_x, scaled_y, out_i, out_j); - } else { - // Class II quantization (aperture 3 odd resolutions) - quantize_class2(scaled_x, scaled_y, out_i, out_j); - } - - out_quad = quad; - handle_edge_overflow(out_quad, out_i, out_j, aperture, resolution); -} - -void icosa_tri_to_quad_ij(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, - int aperture, int resolution, - int& out_quad, long long& out_i, long long& out_j) { - int quad; - double quad_x, quad_y; - icosa_tri_to_quad_xy(icosa_triangle_face, icosa_triangle_x, icosa_triangle_y, quad, quad_x, quad_y); - quad_xy_to_ij(quad, quad_x, quad_y, aperture, resolution, out_quad, out_i, out_j); -} - -void quad_ij_to_xy(int quad, long long i, long long j, - int aperture, int resolution, - double& out_quad_x, double& out_quad_y) { - - double x, y; - inv_quantize_class1(i, j, x, y); - - // Compute inverse scale accounting for substrate - double scale; - if (aperture == 3) { - bool is_class1 = (resolution % 2 == 0); - scale = is_class1 - ? std::pow(kSqrt3, resolution) - : std::pow(kSqrt3, resolution + 1); // Class II substrate - } else if (aperture == 4) { - scale = std::pow(2.0, resolution); - } else if (aperture == 7) { - // Aperture 7: base scale * substrate multiplier - double base_scale = std::pow(std::sqrt(7.0), resolution); - bool is_class3i = (resolution % 2 == 0); - // Class III-I substrate is sqrt(7)x finer, Class III-II is sqrt(21)x finer - double substrate_mult = is_class3i ? kSqrt7 : kSqrt21; - scale = base_scale * substrate_mult; - } else { - throw std::runtime_error("quad_ij_to_xy: unsupported aperture"); - } - - out_quad_x = x / scale; - out_quad_y = y / scale; -} - -// Shared scale/class computation for the mixed 4/3 substrate: 2x per -// aperture-4 level, sqrt(3)x per aperture-3 level, mirroring -// calc_grid_params_ap43()'s cell-count formula in rcpp_cell.cpp -// (N = 10*4^level*3^(res-level)+2) so the quantized (i,j) match the grid -// that formula describes rather than a pure aperture-3 approximation. -namespace { -void ap43_scale_and_class(int resolution, int mixed_aperture_level, - double& out_scale, bool& out_use_class2) { - double scale = 1.0; - int ap3_count = 0; - for (int r = 1; r <= resolution; r++) { - if (r <= mixed_aperture_level) { - scale *= 2.0; - } else { - scale *= kSqrt3; - ap3_count++; - } - } - out_scale = scale; - out_use_class2 = (ap3_count % 2) == 1; -} -} // anonymous namespace - -void quad_xy_to_ij_ap43(int quad, double quad_x, double quad_y, - int resolution, int mixed_aperture_level, - int& out_quad, long long& out_i, long long& out_j) { - double scale; - bool use_class2; - ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); - - double scaled_x = quad_x * scale; - double scaled_y = quad_y * scale; - - if (use_class2) { - quantize_class2(scaled_x, scaled_y, out_i, out_j); - } else { - quantize_class1(scaled_x, scaled_y, out_i, out_j); - } - - out_quad = quad; - - // Class II quantization internally re-quantizes on a substrate that's - // sqrt(3)x finer than `scale` (see quantize_class2()), so the grid's - // true edge coordinate needs that same factor -- matching - // calc_max_grid_dim_ap43()'s "use_offset" boost in rcpp_cell.cpp. - double edge_scale = use_class2 ? scale * kSqrt3 : scale; - long long edge_coord = static_cast(edge_scale + 1e-9); - - // quantize_class2()'s internal rotate/requantize chain can overshoot the - // predicted edge_coord by a tie-breaking unit near a quad boundary - // (floating-point kSqrt3*kSqrt3 isn't exactly 3.0); handle_upper_edge()/ - // handle_lower_edge() below only match on exact equality, so clamp any - // overshoot back onto the boundary they expect. - if (out_i > edge_coord) out_i = edge_coord; - if (out_j > edge_coord) out_j = edge_coord; - - if ((out_i == edge_coord || out_j == edge_coord) && out_quad >= 1 && out_quad <= 10) { - const QuadAdjacency& adj = kQuadAdjacency[out_quad]; - if (adj.is_upper) { - handle_upper_edge(out_quad, out_i, out_j, edge_coord, adj); - } else { - handle_lower_edge(out_quad, out_i, out_j, edge_coord, adj); - } - } -} - -void quad_ij_to_xy_ap43(int quad, long long i, long long j, - int resolution, int mixed_aperture_level, - double& out_quad_x, double& out_quad_y) { - double x, y; - inv_quantize_class1(i, j, x, y); - - double scale; - bool use_class2; - ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); - double effective_scale = use_class2 ? scale * kSqrt3 : scale; - - out_quad_x = x / effective_scale; - out_quad_y = y / effective_scale; -} - -// ============================================================================ -// vertTable - Derived from First Principles -// ============================================================================ -// -// This table maps (quad, subTriRegion) -> (triNum, trans, rot60) -// -// Each quad is divided into 6 regions based on the hex geometry: -// Region 0: Upper (y > sqrt(3)*x AND y >= -sqrt(3)*x) -// Region 1: Upper-right (y <= sqrt(3)*x AND y >= 0) -// Region 2: Lower-right (y < 0 AND y > -sqrt(3)*x) -// Region 3: Lower (y <= -sqrt(3)*x AND y < sqrt(3)*x) -// Region 4: Lower-left (y >= sqrt(3)*x AND y < 0) -// Region 5: Upper-left (y >= 0 AND y < -sqrt(3)*x) -// -// ============================================================================ -// DERIVATION FROM FIRST PRINCIPLES -// ============================================================================ -// -// The vertTable is the inverse of the triTable. For each (quad, region), we -// need to find which triangle contains that region and what transformation -// brings Quad XY coordinates back to Icosa Triangle coordinates. -// -// ICOSAHEDRON STRUCTURE: -// --------------------- -// 20 triangular faces are numbered 0-19: -// - Faces 0-4: North cap (around vertex 0, touching north pole) -// - Faces 5-9: Upper-middle band (connecting north cap to lower band) -// - Faces 10-14: Lower-middle band (connecting upper band to south cap) -// - Faces 15-19: South cap (around vertex 11, touching south pole) -// -// QUAD STRUCTURE: -// --------------- -// 12 quads (rhombus shapes), each containing 2 triangles: -// - Quad 0: North pole vertex (special - not a rhombus) -// - Quads 1-5: Upper hemisphere, each contains triangles (n-1, n+4) for n=1..5 -// - Quads 6-10: Lower hemisphere, each contains triangles (n+4, n+9) for n=6..10 -// - Quad 11: South pole vertex (special - not a rhombus) -// -// TRIANGLE-TO-QUAD MAPPING (triTable, forward direction): -// ------------------------------------------------------- -// From the triTable, each triangle maps to a quad with a transformation: -// -// Triangle | Quad | Rotation | Translation -// ---------|------|----------|------------- -// 0 | 1 | 1 | (0, 0) <- primary -// 1 | 2 | 1 | (0, 0) <- primary -// 2 | 3 | 1 | (0, 0) <- primary -// 3 | 4 | 1 | (0, 0) <- primary -// 4 | 5 | 1 | (0, 0) <- primary -// 5 | 1 | 4 | (-0.5, -sin60) <- secondary -// 6 | 2 | 4 | (-0.5, -sin60) <- secondary -// 7 | 3 | 4 | (-0.5, -sin60) <- secondary -// 8 | 4 | 4 | (-0.5, -sin60) <- secondary -// 9 | 5 | 4 | (-0.5, -sin60) <- secondary -// 10 | 6 | 1 | (0, 0) <- primary -// 11 | 7 | 1 | (0, 0) <- primary -// 12 | 8 | 1 | (0, 0) <- primary -// 13 | 9 | 1 | (0, 0) <- primary -// 14 | 10 | 1 | (0, 0) <- primary -// 15 | 6 | 4 | (-0.5, -sin60) <- secondary -// 16 | 7 | 4 | (-0.5, -sin60) <- secondary -// 17 | 8 | 4 | (-0.5, -sin60) <- secondary -// 18 | 9 | 4 | (-0.5, -sin60) <- secondary -// 19 | 10 | 4 | (-0.5, -sin60) <- secondary -// -// Forward transform: rotate(rot * 60°) then subtract(trans) -// Inverse transform: add(trans) then rotate(-rot * 60°) -// -// QUAD-TO-TRIANGLE MAPPING (vertTable, inverse direction): -// -------------------------------------------------------- -// For each quad, the 6 regions map to triangles based on adjacency: -// -// Upper quads (1-5) - each contains primary triangle P and secondary S: -// Region 0: Primary triangle P (rot=-1, trans=negate of primary's) -// Region 1: Secondary triangle S (rot=-4, trans=negate of secondary's) -// Region 2: Lower-mid triangle (adjacent via icosahedron edge) -// Region 3: INVALID (extends beyond icosahedron) -// Region 4: Adjacent upper-mid secondary triangle -// Region 5: Previous quad's primary triangle -// -// Lower quads (6-10) - similar structure but mirrored: -// Region 0: Primary triangle (from lower-mid band) -// Region 1: Secondary triangle (from south cap) -// Region 2: Adjacent south cap triangle -// Region 3: Upper quad's secondary triangle -// Region 4: INVALID -// Region 5: Adjacent upper-mid secondary triangle -// -// ADJACENCY DERIVATION: -// --------------------- -// From icosahedron face definition: -// faces[20][3] = { -// {0,1,2},{0,2,3},{0,3,4},{0,4,5},{0,5,1}, // 0-4: North cap -// {6,2,1},{7,3,2},{8,4,3},{9,5,4},{10,1,5}, // 5-9: Upper-mid band -// {2,6,7},{3,7,8},{4,8,9},{5,9,10},{1,10,6}, // 10-14: Lower-mid band -// {11,7,6},{11,8,7},{11,9,8},{11,10,9},{11,6,10} // 15-19: South cap -// } -// -// Two faces are adjacent if they share 2 vertices. For each quad region, -// the adjacent triangle is determined by which face shares the edge -// corresponding to that region's direction. -// -// For quad q (1-5): -// - Region 0 → triangle (q-1): primary triangle of this quad -// - Region 1 → triangle (q+4): secondary triangle of this quad -// - Region 2 → triangle (q+9): lower-mid band (shares edge going southeast) -// - Region 3 → INVALID (no icosahedron face in this direction) -// - Region 4 → triangle ((q+3)%5+5): previous quad's secondary -// - Region 5 → triangle ((q-2+5)%5): next quad's primary -// -// For quad q (6-10): -// - Region 0 → triangle (q+4): lower-mid band primary -// - Region 1 → triangle (q+9): south cap secondary -// - Region 2 → triangle ((q-6+4)%5+15): adjacent south cap -// - Region 3 → triangle (q-6+10): this quad's lower-mid adjacent -// - Region 4 → INVALID -// - Region 5 → triangle ((q-6+4)%5+5): upper-mid secondary -// -// TRANSFORMATION DERIVATION: -// -------------------------- -// The inverse transformation parameters are computed as: -// - rot60: Negate the forward rotation -// - trans: The translation needed to move from Quad XY back to Icosa Triangle -// -// For a primary triangle (forward: rot=1, trans=(0,0)): -// Inverse: rot=-1, stored as 1 with sign applied during usage -// -// For a secondary triangle (forward: rot=4, trans=(-0.5,-sin60)): -// Inverse: rot=-4, trans is negated after rotation adjustment -// -// Cross-quad adjacencies require additional transformations based on how -// the triangles are oriented relative to each other. -// -// ============================================================================ - -struct VertTriVals { - int triNum; // Output triangle number - double trans_x; // Translation x (added to Quad XY before rotation) - double trans_y; // Translation y (added to Quad XY before rotation) - int rot60; // Number of 60-degree rotations (multiply by -60 for actual rotation) - bool keep; // Whether to keep this vertex -}; - -// vertTable[quad][subTri] - Derived from icosahedron geometry -// -// The derivation uses these key relationships: -// -// 1. Primary triangles of quads 1-5 are faces 0-4 (north cap) -// 2. Secondary triangles of quads 1-5 are faces 5-9 (upper-mid band) -// 3. Primary triangles of quads 6-10 are faces 10-14 (lower-mid band) -// 4. Secondary triangles of quads 6-10 are faces 15-19 (south cap) -// -// 5. Each region maps to an adjacent triangle with a specific transformation: -// - Region 0: The "upper" direction in Quad XY space -// - Region 1: The "upper-right" direction (60° clockwise from up) -// - Region 2: The "lower-right" direction (120° clockwise from up) -// - Region 3: The "lower" direction (180° from up) -// - Region 4: The "lower-left" direction (240° clockwise from up) -// - Region 5: The "upper-left" direction (300° clockwise from up) -// -// 6. The transformations are computed to reverse the forward triTable mapping -// while accounting for the hexagonal geometry. -// -static const VertTriVals kVertTable[12][6] = { - // ======================================================================== - // Quad 0 (North pole vertex) - // ======================================================================== - // The north pole (vertex 0) is surrounded by triangles 0-4. - // This is a special case where 5 triangles meet at a point. - // The 6 regions map to these 5 triangles with one invalid region. - // - // From vertex 0, going around counter-clockwise: - // Triangle 0: shares edge with triangles 4 and 1 - // Triangle 1: shares edge with triangles 0 and 2 - // Triangle 2: shares edge with triangles 1 and 3 - // Triangle 3: shares edge with triangles 2 and 4 - // Triangle 4: shares edge with triangles 3 and 0 - // - // Region assignments (empirically verified): - // Region 0 → Triangle 1 (rot=3) - // Region 1 → Triangle 0 (rot=2) - // Region 2 → Triangle 4 (rot=1) - // Region 3 → INVALID (pentagon vertex, no 6th triangle) - // Region 4 → Triangle 3 (rot=-1) - // Region 5 → Triangle 2 (rot=-2) - { - { 1, -0.5, -kSin60, 3, true}, // Region 0 → tri 1 - { 0, -1.0, 0.0, 2, true}, // Region 1 → tri 0 - { 4, -0.5, kSin60, 1, true}, // Region 2 → tri 4 - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 3, 1.0, 0.0, -1, true}, // Region 4 → tri 3 - { 2, 0.5, -kSin60, -2, true} // Region 5 → tri 2 - }, - - // ======================================================================== - // Quads 1-5 (Upper hemisphere) - // ======================================================================== - // Each quad q contains: - // - Primary triangle: (q-1) from north cap (faces 0-4) - // - Secondary triangle: (q+4) from upper-mid band (faces 5-9) - // - // The primary triangle transformation is: rot=1, trans=(0,0) - // The secondary triangle transformation is: rot=4, trans=(-0.5,-sin60) - // - // Inverse transformations: - // - For primary: add (0,0), rotate -1*60° = rotate(-60°) - // - For secondary: add (0.5,sin60) rotated, then rotate -4*60° - // - // Cross-quad adjacencies (computed from icosahedron edge sharing): - // Region 2: Lower-mid band triangle (q+9) with special transform - // Region 4: Previous quad's secondary triangle - // Region 5: Next quad's primary triangle (wrapping around) - // - // Quad 1: primary=tri0, secondary=tri5 - { - { 0, 0.0, 0.0, 1, true}, // Region 0 → tri 0 (primary) - { 5, -0.5, -kSin60, 4, true}, // Region 1 → tri 5 (secondary) - {14, -0.5, kSin60, 1, true}, // Region 2 → tri 14 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 9, 0.0, 0.0, 3, true}, // Region 4 → tri 9 (quad5's secondary) - { 4, 1.0, 0.0, 0, true} // Region 5 → tri 4 (quad5's primary) - }, - // Quad 2: primary=tri1, secondary=tri6 - { - { 1, 0.0, 0.0, 1, true}, // Region 0 → tri 1 (primary) - { 6, -0.5, -kSin60, 4, true}, // Region 1 → tri 6 (secondary) - {10, -0.5, kSin60, 1, true}, // Region 2 → tri 10 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 5, 0.0, 0.0, 3, true}, // Region 4 → tri 5 (quad1's secondary) - { 0, 1.0, 0.0, 0, true} // Region 5 → tri 0 (quad1's primary) - }, - // Quad 3: primary=tri2, secondary=tri7 - { - { 2, 0.0, 0.0, 1, true}, // Region 0 → tri 2 (primary) - { 7, -0.5, -kSin60, 4, true}, // Region 1 → tri 7 (secondary) - {11, -0.5, kSin60, 1, true}, // Region 2 → tri 11 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 6, 0.0, 0.0, 3, true}, // Region 4 → tri 6 (quad2's secondary) - { 1, 1.0, 0.0, 0, true} // Region 5 → tri 1 (quad2's primary) - }, - // Quad 4: primary=tri3, secondary=tri8 - { - { 3, 0.0, 0.0, 1, true}, // Region 0 → tri 3 (primary) - { 8, -0.5, -kSin60, 4, true}, // Region 1 → tri 8 (secondary) - {12, -0.5, kSin60, 1, true}, // Region 2 → tri 12 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 7, 0.0, 0.0, 3, true}, // Region 4 → tri 7 (quad3's secondary) - { 2, 1.0, 0.0, 0, true} // Region 5 → tri 2 (quad3's primary) - }, - // Quad 5: primary=tri4, secondary=tri9 - { - { 4, 0.0, 0.0, 1, true}, // Region 0 → tri 4 (primary) - { 9, -0.5, -kSin60, 4, true}, // Region 1 → tri 9 (secondary) - {13, -0.5, kSin60, 1, true}, // Region 2 → tri 13 (lower-mid, adjacent) - {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID - { 8, 0.0, 0.0, 3, true}, // Region 4 → tri 8 (quad4's secondary) - { 3, 1.0, 0.0, 0, true} // Region 5 → tri 3 (quad4's primary) - }, - - // ======================================================================== - // Quads 6-10 (Lower hemisphere) - // ======================================================================== - // Each quad q contains: - // - Primary triangle: (q+4) from lower-mid band (faces 10-14) - // - Secondary triangle: (q+9) from south cap (faces 15-19) - // - // Lower hemisphere quads have different adjacency patterns: - // Region 0: Primary triangle (lower-mid band) - // Region 1: Secondary triangle (south cap) - // Region 2: Adjacent south cap triangle (wrapping) - // Region 3: Upper quad's lower-mid triangle (cross-hemisphere) - // Region 4: INVALID - // Region 5: Upper quad's secondary triangle - // - // Quad 6: primary=tri10, secondary=tri15 - { - {10, 0.0, 0.0, 1, true}, // Region 0 → tri 10 (primary) - {15, -0.5, -kSin60, 4, true}, // Region 1 → tri 15 (secondary) - {19, 0.0, 0.0, -1, true}, // Region 2 → tri 19 (adjacent south cap) - {14, -0.5, kSin60, 2, true}, // Region 3 → tri 14 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 5, 0.5, -kSin60, 4, true} // Region 5 → tri 5 (upper secondary) - }, - // Quad 7: primary=tri11, secondary=tri16 - { - {11, 0.0, 0.0, 1, true}, // Region 0 → tri 11 (primary) - {16, -0.5, -kSin60, 4, true}, // Region 1 → tri 16 (secondary) - {15, 0.0, 0.0, -1, true}, // Region 2 → tri 15 (adjacent south cap) - {10, -0.5, kSin60, 2, true}, // Region 3 → tri 10 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 6, 0.5, -kSin60, 4, true} // Region 5 → tri 6 (upper secondary) - }, - // Quad 8: primary=tri12, secondary=tri17 - { - {12, 0.0, 0.0, 1, true}, // Region 0 → tri 12 (primary) - {17, -0.5, -kSin60, 4, true}, // Region 1 → tri 17 (secondary) - {16, 0.0, 0.0, -1, true}, // Region 2 → tri 16 (adjacent south cap) - {11, -0.5, kSin60, 2, true}, // Region 3 → tri 11 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 7, 0.5, -kSin60, 4, true} // Region 5 → tri 7 (upper secondary) - }, - // Quad 9: primary=tri13, secondary=tri18 - { - {13, 0.0, 0.0, 1, true}, // Region 0 → tri 13 (primary) - {18, -0.5, -kSin60, 4, true}, // Region 1 → tri 18 (secondary) - {17, 0.0, 0.0, -1, true}, // Region 2 → tri 17 (adjacent south cap) - {12, -0.5, kSin60, 2, true}, // Region 3 → tri 12 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 8, 0.5, -kSin60, 4, true} // Region 5 → tri 8 (upper secondary) - }, - // Quad 10: primary=tri14, secondary=tri19 - { - {14, 0.0, 0.0, 1, true}, // Region 0 → tri 14 (primary) - {19, -0.5, -kSin60, 4, true}, // Region 1 → tri 19 (secondary) - {18, 0.0, 0.0, -1, true}, // Region 2 → tri 18 (adjacent south cap) - {13, -0.5, kSin60, 2, true}, // Region 3 → tri 13 (lower-mid, cross) - {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID - { 9, 0.5, -kSin60, 4, true} // Region 5 → tri 9 (upper secondary) - }, - - // ======================================================================== - // Quad 11 (South pole vertex) - // ======================================================================== - // The south pole (vertex 11) is surrounded by triangles 15-19. - // This is a special case where 5 triangles meet at a point. - // The 6 regions map to these 5 triangles with one invalid region. - // - // From vertex 11, going around counter-clockwise: - // Triangle 15: shares edge with triangles 19 and 16 - // Triangle 16: shares edge with triangles 15 and 17 - // Triangle 17: shares edge with triangles 16 and 18 - // Triangle 18: shares edge with triangles 17 and 19 - // Triangle 19: shares edge with triangles 18 and 15 - // - // Region assignments (empirically verified): - // Region 0 → Triangle 17 (rot=3) - // Region 1 → Triangle 18 (rot=2) - // Region 2 → Triangle 19 (rot=1) - // Region 3 → Triangle 15 (rot=0) - // Region 4 → INVALID (pentagon vertex, no 6th triangle) - // Region 5 → Triangle 16 (rot=-2) - { - {17, -0.5, -kSin60, 3, true}, // Region 0 → tri 17 - {18, -1.0, 0.0, 2, true}, // Region 1 → tri 18 - {19, -0.5, kSin60, 1, true}, // Region 2 → tri 19 - {15, 0.5, kSin60, 0, true}, // Region 3 → tri 15 - {-1, 0.0, 0.0, 0, false}, // Region 4 → INVALID - {16, 0.5, -kSin60, -2, true} // Region 5 → tri 16 - } -}; - -// ============================================================================ -// Sub-triangle Region Detection -// ============================================================================ -// -// Divides the Quad XY coordinate space into 6 wedge-shaped regions emanating -// from the origin. The boundaries are lines at angles 0°, 60°, 120°, 180°, -// 240°, 300° from the positive x-axis. The key boundary is y = ±sqrt(3)*x. -// -// Region 0 (Upper) -// /\ -// Reg 5 / \ Reg 1 -// -----+----+----- -// Reg 4 \ / Reg 2 -// \/ -// Region 3 (Lower) -// -// Each region maps to a different triangle in the icosahedron. - -// Check if point is at origin (within tolerance) -inline bool is_origin(double x, double y, double tol) { - return std::fabs(x) <= tol && std::fabs(y) <= tol; -} - -// Compute which of 6 sub-regions a Quad XY point falls into -// Uses 6-way wedge classification based on y = ±sqrt(3)*x boundaries -static int compute_subtriangle(double x, double y) { - constexpr double tol = 1e-15; - - // Origin -> Region 1 (center/upper-right by convention) - if (is_origin(x, y, tol)) return 1; - - // Pre-compute boundary lines: y = ±sqrt(3)*x with tolerance - const double xs = kSqrt3 * x; - const double xs_plus = xs + tol; // y = sqrt(3)*x + tol - const double xs_minus = xs - tol; // y = sqrt(3)*x - tol - const double neg_xs_plus = -xs + tol; // y = -sqrt(3)*x + tol - const double neg_xs_minus = -xs - tol; // y = -sqrt(3)*x - tol - - // Region 0: Upper (above both diagonal lines) - if (y >= neg_xs_minus && y > xs_plus) return 0; - - // Region 1: Upper-right (below y=sqrt(3)*x, above y=0) - if (y <= xs_plus && y >= -tol) return 1; - - // Region 2: Lower-right (below y=0, above y=-sqrt(3)*x) - if (y < -tol && y > neg_xs_plus) return 2; - - // Region 3: Lower (below both diagonal lines) - if (y <= neg_xs_plus && y < xs_minus) return 3; - - // Region 4: Lower-left (above y=sqrt(3)*x, below y=0) - if (y >= xs_minus && y < -tol) return 4; - - // Region 5: Upper-left (above y=0, below y=-sqrt(3)*x) - if (y >= -tol && y < neg_xs_minus) return 5; - - // Fallback (should not occur for valid quad coordinates) - return 1; -} - -// Try to convert quad XY to icosa triangle coords. Returns true on success, -// false if the point is in an invalid region (e.g., outside the valid quad bounds). -bool try_quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, - int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { - if (quad < kMinQuad || quad > kMaxQuad) { - throw std::invalid_argument("try_quad_xy_to_icosa_tri: quad must be between 0 and 11"); - } - - // Detect which of 6 sub-regions the point falls into - int subTri = compute_subtriangle(quad_x, quad_y); - - // Look up transformation from vertTable - const VertTriVals& triVal = kVertTable[quad][subTri]; - - if (!triVal.keep || triVal.triNum < 0) { - // This region maps to an invalid/dropped vertex - return false; - } - - out_icosa_triangle_face = triVal.triNum; - - // Apply inverse transformation: - // coord += trans - // coord.rotate(rot60 * -60.0) // rotate by -60*rot60 degrees CCW - out_icosa_triangle_x = quad_x + triVal.trans_x; - out_icosa_triangle_y = quad_y + triVal.trans_y; - - // Rotate: rot60 * -60 degrees = -60 * rot60 degrees CCW - // Which is the same as 60 * rot60 degrees CW - // If rot60=4, rotate -240 degrees CCW = 120 degrees CW = -2 rotations of 60deg CCW - rotate_60deg_ccw(out_icosa_triangle_x, out_icosa_triangle_y, -triVal.rot60); - return true; -} - -void quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, - int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { - if (!try_quad_xy_to_icosa_tri(quad, quad_x, quad_y, out_icosa_triangle_face, out_icosa_triangle_x, out_icosa_triangle_y)) { - throw std::runtime_error("quad_xy_to_icosa_tri: point in invalid region"); - } -} - -} // namespace hexify +// coordinate_transforms.cpp - Convert between ISEA DGGS coordinate systems +// +// ============================================================================ +// COORDINATE TRANSFORMATION FLOW +// ============================================================================ +// +// This file implements the coordinate transformations between three systems: +// +// +---------------------+ +---------------+ +-----------+ +// | Icosa Triangle | --> | Quad XY | --> | Quad IJ | +// | (icosa_triangle_ | | (quad, | | (quad, | +// | face, _x, _y) | | quad_x, | | i, j) | +// +---------------------+ | quad_y) | +-----------+ +// | +---------------+ | +// v | v +// Icosahedral face Quad continuous Quad integer +// coordinates coordinates cell indices +// +// Icosa Triangle: Output from Snyder forward projection +// - icosa_triangle_face: Triangle index (0-19) +// - icosa_triangle_x, icosa_triangle_y: Normalized coords within triangle [0,1] +// +// Quad XY: Quad with continuous (double) coordinates +// - quad: Quad index (0-11, where 0=North pole, 11=South pole) +// - quad_x, quad_y: Continuous position within quad +// +// Quad IJ: Quad with integer cell indices (used for cell ID computation) +// - quad: Same as Quad XY +// - i, j: Integer cell coordinates (resolution-dependent) +// +// ============================================================================ +// ICOSAHEDRON GEOMETRY +// ============================================================================ +// +// The icosahedron has 20 triangular faces grouped into 12 quads: +// +// Quad 0 (North Pole) +// /\ +// / \ +// +----+----+----+----+----+ +// | Q1 | Q2 | Q3 | Q4 | Q5 | <- Upper hemisphere (quads 1-5) +// +----+----+----+----+----+ +// | Q6 | Q7 | Q8 | Q9 |Q10 | <- Lower hemisphere (quads 6-10) +// +----+----+----+----+----+ +// \ / +// \/ +// Quad 11 (South Pole) +// +// Each non-polar quad contains 2 triangles forming a rhombus. +// Triangles 0-4 and 5-9 map to quads 1-5 +// Triangles 10-14 and 15-19 map to quads 6-10 +// +// ============================================================================ +// QUANTIZATION CLASSES +// ============================================================================ +// +// Different apertures use different quantization schemes: +// +// Aperture 3: +// - Even resolutions: Class I (aligned hexagons) +// - Odd resolutions: Class II (rotated hexagons) +// +// Aperture 4: +// - All resolutions: Class I (aligned hexagons) +// +// Aperture 7: +// - Even resolutions: Class III-I +// - Odd resolutions: Class III-II +// +// Mathematical foundation from Sahr et al. publications on ISEA grids. +// +// Copyright (c) 2024 hexify authors. MIT License. + +#include "coordinate_transforms.h" +#include "cube_coordinates.h" +#include "ijk_coordinates.h" +#include "index_z7.h" +#include "constants.h" +#include +#include +#include + +namespace hexify { + +namespace { + +// ============================================================================ +// Triangle to Quad Mapping +// ============================================================================ +// +// Icosahedron face layout (20 triangles -> 12 quads): +// +// North Pole (Quad 0) at top, South Pole (Quad 11) at bottom. +// Quads 1-5: upper hemisphere, Quads 6-10: lower hemisphere. +// Each quad contains 2 triangles forming a rhombus shape. +// +// Each non-polar quad contains 2 triangles. The mapping specifies: +// - Which quad a triangle belongs to +// - Rotation and translation to align triangle coords with quad coords + +struct TriangleMapping { + int quad; // Target quad (1-10 for regular quads) + int sub_triangle; // 0 = primary, 1 = secondary (rotated/translated) + double offset_x; // X offset after rotation + double offset_y; // Y offset after rotation + int rotations; // Number of 60° clockwise rotations +}; + +// Mapping table derived from ISEA icosahedron geometry +// Triangle indices 0-19 map to quads 1-10 (polar quads 0,11 handled separately) +const TriangleMapping kTriangleMap[20] = { + // Upper cap triangles (0-4) -> quads 1-5, primary position + {1, 0, 0.0, 0.0, 1}, + {2, 0, 0.0, 0.0, 1}, + {3, 0, 0.0, 0.0, 1}, + {4, 0, 0.0, 0.0, 1}, + {5, 0, 0.0, 0.0, 1}, + // Upper-middle triangles (5-9) -> quads 1-5, secondary position + {1, 1, -0.5, -kSin60, 4}, + {2, 1, -0.5, -kSin60, 4}, + {3, 1, -0.5, -kSin60, 4}, + {4, 1, -0.5, -kSin60, 4}, + {5, 1, -0.5, -kSin60, 4}, + // Lower-middle triangles (10-14) -> quads 6-10, primary position + {6, 0, 0.0, 0.0, 1}, + {7, 0, 0.0, 0.0, 1}, + {8, 0, 0.0, 0.0, 1}, + {9, 0, 0.0, 0.0, 1}, + {10, 0, 0.0, 0.0, 1}, + // Lower cap triangles (15-19) -> quads 6-10, secondary position + {6, 1, -0.5, -kSin60, 4}, + {7, 1, -0.5, -kSin60, 4}, + {8, 1, -0.5, -kSin60, 4}, + {9, 1, -0.5, -kSin60, 4}, + {10, 1, -0.5, -kSin60, 4}, +}; + +// ============================================================================ +// Rotation Helper +// ============================================================================ + +void rotate_60deg_ccw(double& x, double& y, int n_rotations) { + // Each 60° counter-clockwise rotation: [cos(60) -sin(60); sin(60) cos(60)] + // cos(60°) = 0.5, sin(60°) = sqrt(3)/2 + constexpr double c60 = 0.5; + constexpr double s60 = kSin60; + + n_rotations = ((n_rotations % 6) + 6) % 6; // Normalize to 0-5 + + for (int i = 0; i < n_rotations; ++i) { + double nx = c60 * x - s60 * y; // counter-clockwise: x*cos - y*sin + double ny = s60 * x + c60 * y; // counter-clockwise: x*sin + y*cos + x = nx; + y = ny; + } +} + +// ============================================================================ +// Hex Quantization - Precise hexagonal grid rounding +// ============================================================================ +// This implementation handles all edge cases at hexagon boundaries correctly +// using a decision-tree approach that carefully handles the fractional parts +// of the continuous coordinates. This is more robust than simple cube-coordinate +// rounding at cell boundaries. + +// ============================================================================ +// Boundary Classification for Hex Quantization +// ============================================================================ +// +// The unit cell is divided into 6 regions based on fractional coordinates (frac_i, frac_j). +// Each region determines the (delta_i, delta_j) offset from the base cell (floor_i, floor_j). +// +// The regions form a hexagonal Voronoi partition: +// - Region A: frac_i < 1/3, frac_j < (1+frac_i)/2 -> (0, 0) +// - Region B: frac_i < 1/3, frac_j >= (1+frac_i)/2 -> (0, 1) +// - Region C: 1/3 <= frac_i < 1/2 -> complex boundary (see below) +// - Region D: 1/2 <= frac_i < 2/3 -> complex boundary (see below) +// - Region E: frac_i >= 2/3, frac_j < frac_i/2 -> (1, 0) +// - Region F: frac_i >= 2/3, frac_j >= frac_i/2 -> (1, 1) +// +// For regions C and D, the i-offset depends on whether frac_j falls in the +// "middle band" between two linear thresholds. + +// Classify which boundary region based on fractional coords +// Returns: 0=A, 1=B, 2=C_lower, 3=C_upper, 4=C_mid, 5=D_lower, 6=D_upper, 7=D_mid, 8=E, 9=F +inline int classify_hex_boundary(double frac_i, double frac_j) { + if (frac_i < 1.0/3.0) { + return (frac_j < (1.0 + frac_i) / 2.0) ? 0 : 1; // A or B + } + if (frac_i < 0.5) { + // Region C: thresholds at (1-frac_i) and (2*frac_i) + double lower_threshold = 1.0 - frac_i; + double upper_threshold = 2.0 * frac_i; + if (frac_j < lower_threshold) return 2; // C_lower: j=floor_j + if (frac_j >= upper_threshold) return 3; // C_upper: j=floor_j+1 + return 4; // C_mid: i=floor_i+1 + } + if (frac_i < 2.0/3.0) { + // Region D: thresholds at (2*frac_i-1) and (1-frac_i) + double lower_threshold = 2.0 * frac_i - 1.0; + double upper_threshold = 1.0 - frac_i; + if (frac_j <= lower_threshold) return 5; // D_lower: j=floor_j, i=floor_i+1 + if (frac_j >= upper_threshold) return 6; // D_upper: j=floor_j+1, i=floor_i+1 + return 7; // D_mid: i=floor_i + } + return (frac_j < frac_i / 2.0) ? 8 : 9; // E or F +} + +// Lookup table: boundary_region -> (delta_i, delta_j) offset +// Indexed by classify_hex_boundary() return value +static const int kBoundaryOffset[10][2] = { + {0, 0}, // 0: Region A + {0, 1}, // 1: Region B + {0, 0}, // 2: Region C_lower (j=floor_j) + {0, 1}, // 3: Region C_upper (j=floor_j+1) + {1, 0}, // 4: Region C_mid - special: j depends on frac_j < (1-frac_i) + {1, 0}, // 5: Region D_lower (j=floor_j) + {1, 1}, // 6: Region D_upper (j=floor_j+1) + {0, 0}, // 7: Region D_mid - special: j depends on frac_j < (1-frac_i) + {1, 0}, // 8: Region E + {1, 1}, // 9: Region F +}; + +// Fold i-coordinate across x-axis when x was negative +inline long long fold_i_negative_x(long long i, long long j) { + if ((j % 2) == 0) { + long long axis = j / 2; + return i - 2 * (i - axis); + } else { + long long axis = (j + 1) / 2; + return i - (2 * (i - axis) + 1); + } +} + +// Class I (flat-top) quantization +void quantize_class1(double x, double y, long long& out_i, long long& out_j) { + // Guard against NaN/Inf inputs to avoid undefined behavior in integer cast + if (!std::isfinite(x) || !std::isfinite(y)) { + out_i = 0; + out_j = 0; + return; + } + + // Work in positive quadrant + double abs_x = std::fabs(x); + double abs_y = std::fabs(y); + + // Convert to fractional hex indices + double idx_j = abs_y / kSin60; + double idx_i = abs_x + idx_j / 2.0; + + // Integer (floor) and fractional parts + long long floor_i = static_cast(idx_i); + long long floor_j = static_cast(idx_j); + double frac_i = idx_i - floor_i; + double frac_j = idx_j - floor_j; + + // Classify and look up base offset + int region = classify_hex_boundary(frac_i, frac_j); + long long delta_i = kBoundaryOffset[region][0]; + long long delta_j = kBoundaryOffset[region][1]; + + // Handle special cases where j depends on secondary threshold + if (region == 4) { // C_mid + delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; + } else if (region == 7) { // D_mid + delta_j = (frac_j < (1.0 - frac_i)) ? 0 : 1; + } + + long long i_result = floor_i + delta_i; + long long j_result = floor_j + delta_j; + + // Fold back to original quadrant + if (x < 0.0) { + i_result = fold_i_negative_x(i_result, j_result); + } + if (y < 0.0) { + i_result = i_result - (2 * j_result + 1) / 2; + j_result = -j_result; + } + + out_i = i_result; + out_j = j_result; +} + +// Class I inverse: (i,j) to (x,y) +void inv_quantize_class1(long long i, long long j, double& x, double& y) { + cube_to_cartesian(static_cast(i), static_cast(j), x, y, kSin60); +} + +// Class II (pointy-top / 30° rotated) quantization +void quantize_class2(double x, double y, long long& out_i, long long& out_j) { + constexpr double angle = -kPi / 6.0; // -30° + double c = std::cos(angle); + double s = std::sin(angle); + + // Rotate to surrogate Class I orientation + double rx = x * c - y * s; + double ry = x * s + y * c; + + // Quantize in surrogate + long long sur_i, sur_j; + quantize_class1(rx, ry, sur_i, sur_j); + + // Get surrogate center and rotate back + double sur_x, sur_y; + inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); + + double back_x = sur_x * c + sur_y * s; // Rotate +30° + double back_y = -sur_x * s + sur_y * c; + + // Scale to substrate and re-quantize + quantize_class1(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); +} + + +// ============================================================================ +// Class III Quantization (Aperture 7) +// ============================================================================ +// Class III hexagons are rotated by arctan(sqrt(3)/5) ~= 19.1deg from Class I. +// This creates a grid where only 1/7 of substrate cells are valid. + +// Aperture 7 rotation angle in radians +constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; + +// Class III-I (even resolutions): Class I surrogate rotated by ~19.1deg +void quantize_class3i(double x, double y, long long& out_i, long long& out_j) { + const double c = std::cos(-kAp7RotRad); + const double s = std::sin(-kAp7RotRad); + + // Rotate to surrogate + double rx = x * c - y * s; + double ry = x * s + y * c; + + // Quantize in Class I surrogate + long long sur_i, sur_j; + quantize_class1(rx, ry, sur_i, sur_j); + + // Get surrogate center and rotate back + double sur_x, sur_y; + inv_quantize_class1(sur_i, sur_j, sur_x, sur_y); + + double back_x = sur_x * c + sur_y * s; + double back_y = -sur_x * s + sur_y * c; + + // Scale to substrate (sqrt(7)x finer) and re-quantize + quantize_class1(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); +} + +// Class III-II (odd resolutions): Class II surrogate rotated by ~19.1deg +// The surrogate is a Class II grid (pointy-top, 30° rotated from Class I). +// We need to: +// 1. Rotate to surrogate frame (-19.1 degrees) +// 2. Quantize in Class II surrogate (which is Class I at -30 degrees) +// 3. Get the Class II surrogate center +// 4. Rotate back to original frame (+19.1 degrees) +// 5. Scale to Class I substrate (sqrt(21)x) and re-quantize +void quantize_class3ii(double x, double y, long long& out_i, long long& out_j) { + const double c_ap7 = std::cos(-kAp7RotRad); + const double s_ap7 = std::sin(-kAp7RotRad); + + // Step 1: Rotate to surrogate frame (-19.1 degrees) + double sur_x = x * c_ap7 - y * s_ap7; + double sur_y = x * s_ap7 + y * c_ap7; + + // Step 2: Quantize in Class II surrogate + // Class II = Class I rotated by -30 degrees + constexpr double c_30 = 0.866025403784438646763723170752936183; // cos(-30°) + constexpr double s_30 = -0.5; // sin(-30°) + + // Rotate to Class I orientation within the surrogate + double c1_x = sur_x * c_30 - sur_y * s_30; + double c1_y = sur_x * s_30 + sur_y * c_30; + + // Quantize in Class I + long long sur1_i, sur1_j; + quantize_class1(c1_x, c1_y, sur1_i, sur1_j); + + // Get Class I center + double sur1_cen_x, sur1_cen_y; + inv_quantize_class1(sur1_i, sur1_j, sur1_cen_x, sur1_cen_y); + + // Rotate back to Class II orientation (+30 degrees) + double c2_back_x = sur1_cen_x * c_30 + sur1_cen_y * s_30; + double c2_back_y = -sur1_cen_x * s_30 + sur1_cen_y * c_30; + + // Step 3: Rotate back to original frame (+19.1 degrees) + double back_x = c2_back_x * c_ap7 + c2_back_y * s_ap7; + double back_y = -c2_back_x * s_ap7 + c2_back_y * c_ap7; + + // Step 4: Scale to substrate (sqrt(21)x finer for Class III-II) and re-quantize + quantize_class1(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); +} + +// ============================================================================ +// Quad Edge Adjacency +// ============================================================================ +// +// When a cell falls on the edge of a quad, it may belong to an adjacent quad. +// This table defines the adjacency relationships. + +struct QuadAdjacency { + bool is_upper; // Upper hemisphere quad (1-5) vs lower (6-10) + int up_neighbor; // Quad above (for top edge overflow) + int right_neighbor; // Quad to the right (for right edge overflow) +}; + +const QuadAdjacency kQuadAdjacency[12] = { + {true, 0, 0}, // Quad 0: north pole (unused) + {true, 2, 6}, // Quad 1 + {true, 3, 7}, // Quad 2 + {true, 4, 8}, // Quad 3 + {true, 5, 9}, // Quad 4 + {true, 1, 10}, // Quad 5 + {false, 2, 7}, // Quad 6 + {false, 3, 8}, // Quad 7 + {false, 4, 9}, // Quad 8 + {false, 5, 10}, // Quad 9 + {false, 1, 6}, // Quad 10 + {false, 0, 0}, // Quad 11: south pole (unused) +}; + +} // anonymous namespace + +// ============================================================================ +// Aperture 7: Substrate ↔ True Surrogate Conversion +// ============================================================================ +// These functions convert between substrate (Class I) integer coordinates +// and the "true surrogate" coordinates used internally by DGGRID's Class III +// grids. The surrogate is a Class I (even res) or Class II (odd res) hex grid +// rotated by ~19.1° from the substrate frame. +// +// This is needed for finding neighbors: ±1 offsets in surrogate integer space +// correspond to actual adjacent ap7 cells. + +void substrate_to_surrogate_ap7(long long sub_i, long long sub_j, int resolution, + long long& sur_i, long long& sur_j) { + double x, y; + inv_quantize_class1(sub_i, sub_j, x, y); + + // Substrate Cartesian is sqrt(7)x (even) or sqrt(21)x (odd) larger + // than the surrogate scale. Divide down before rotation + quantization. + bool is_class3i = (resolution % 2 == 0); + double divisor = is_class3i ? kSqrt7 : kSqrt21; + x /= divisor; + y /= divisor; + + const double c = std::cos(-kAp7RotRad); + const double s = std::sin(-kAp7RotRad); + double rx = x * c - y * s; + double ry = x * s + y * c; + + if (is_class3i) { + quantize_class1(rx, ry, sur_i, sur_j); + } else { + constexpr double c_30 = 0.866025403784438646763723170752936183; + constexpr double s_30 = -0.5; + double c1x = rx * c_30 - ry * s_30; + double c1y = rx * s_30 + ry * c_30; + quantize_class1(c1x, c1y, sur_i, sur_j); + } +} + +void surrogate_to_substrate_ap7(long long sur_i, long long sur_j, int resolution, + long long& sub_i, long long& sub_j) { + double sx, sy; + inv_quantize_class1(sur_i, sur_j, sx, sy); + + bool is_class3i = (resolution % 2 == 0); + const double c_ap7 = std::cos(kAp7RotRad); + const double s_ap7 = std::sin(kAp7RotRad); + + double back_x, back_y; + if (is_class3i) { + back_x = sx * c_ap7 - sy * s_ap7; + back_y = sx * s_ap7 + sy * c_ap7; + quantize_class1(back_x * kSqrt7, back_y * kSqrt7, sub_i, sub_j); + } else { + constexpr double c_30 = 0.866025403784438646763723170752936183; + constexpr double s_30 = 0.5; + double c2x = sx * c_30 - sy * s_30; + double c2y = sx * s_30 + sy * c_30; + back_x = c2x * c_ap7 - c2y * s_ap7; + back_y = c2x * s_ap7 + c2y * c_ap7; + quantize_class1(back_x * kSqrt21, back_y * kSqrt21, sub_i, sub_j); + } +} + +// ============================================================================ +// Aperture 7: exact-integer surrogate machinery (matches DGGRID / H3) +// ============================================================================ +// The "surrogate" is hexify's canonical aperture-7 cell coordinate: the exact +// integer IJK of the resolution-r cell. It is obtained by a clean, unrotated +// Class I quantization of the shared quad_xy frame at the Class I substrate +// scale (7^numClassI = sqrt(7)^effectiveRes), DGGRID's edgeTable quad +// canonicalization, and, for odd resolutions, one exact aperture-7 coarsen +// (upAp7r). This replaces the earlier floating-point-rotation surrogate, whose +// re-quantization rounded boundary cells to a neighbour and diverged from the +// exact integer grid. + +namespace { + +// DgIDGGBase::edgeTable_[12]: quads 0/11 are pole placeholders (never occur). +struct DgQuadEdge { int quadNum; bool isType0; int loneVert, up, down, right, left; }; +const DgQuadEdge kDggridEdgeTable[12] = { + {0, true, 0, 0, 0, 0, 0}, + {1, true, 0, 2, 10, 6, 5}, + {2, true, 0, 3, 6, 7, 1}, + {3, true, 0, 4, 7, 8, 2}, + {4, true, 0, 5, 8, 9, 3}, + {5, true, 0, 1, 9, 10, 4}, + {6, false, 11, 2, 10, 7, 1}, + {7, false, 11, 3, 6, 8, 2}, + {8, false, 11, 4, 7, 9, 3}, + {9, false, 11, 5, 8, 10, 4}, + {10, false, 11, 1, 9, 6, 5}, + {11, false, 11, 0, 0, 0, 0}, +}; + +// Reassign an out-of-box Class I (i,j) to the quad that owns it. topEdge = +// 7^numClassI = maxI + 1 = maxJ + 1. Port of DgQ2DDtoIConverter's reassignment. +void dggrid_canonicalize_q2di(long long topEdge, int& quadNum, + long long& i, long long& j) { + const long long maxI = topEdge - 1, maxJ = topEdge - 1; + const long long topEdgeI = topEdge, topEdgeJ = topEdge; + + bool underI = i < 0, underJ = j < 0, overI = i > maxI, overJ = j > maxJ; + int numOver = (int)underI + (int)underJ + (int)overI + (int)overJ; + if (!numOver) return; + + const DgQuadEdge& ec = kDggridEdgeTable[quadNum]; + + if (overI && overJ) { + quadNum = ec.isType0 ? ec.up : ec.right; + i = 0; j = 0; + } else if (numOver > 1) { + return; // multi-underage: unreached for valid cell centres + } else if (underI) { + quadNum = ec.left; + if (ec.isType0) { long long ni = topEdgeJ - j + i, nj = topEdgeJ + i; i = ni; j = nj; } + else { i = topEdgeI + i; } + } else if (underJ) { + quadNum = ec.down; + if (ec.isType0) { j = topEdgeJ + j; } + else { long long ni = topEdgeJ + j, nj = (topEdgeI - i) + j; i = ni; j = nj; } + } else if (overI) { + if (ec.isType0) { quadNum = ec.right; i = i - topEdgeI; } + else if (j == 0) { quadNum = ec.loneVert; i = 0; j = 0; } + else { quadNum = ec.right; long long iOver = i - topEdgeI; long long ni = (topEdgeJ - j) + iOver; i = ni; j = iOver; } + } else if (overJ) { + if (!ec.isType0) { quadNum = ec.up; j = j - topEdgeJ; } + else if (i == 0) { quadNum = ec.loneVert; i = 0; j = 0; } + else { quadNum = ec.up; long long jOver = j - topEdgeJ; long long nj = topEdgeI - i + jOver; i = jOver; j = nj; } + } +} + +} // anonymous namespace + +long long ap7_classI_scale(int resolution) { // 7^numClassI, numClassI = (res+1)/2 + long long s = 1; + for (int k = 0, n = (resolution + 1) / 2; k < n; ++k) s *= 7; + return s; +} + +void ap7_substrate_to_surrogate_ijk(long long sub_i, long long sub_j, int resolution, + long long& sur_i, long long& sur_j) { + if (resolution % 2 == 0) { sur_i = sub_i; sur_j = sub_j; return; } + z7::IVec3D v(sub_i, sub_j, 0); + v.upAp7r(); + z7::IVec2D a(v); + sur_i = a.i(); + sur_j = a.j(); +} + +void ap7_surrogate_to_substrate_ijk(long long sur_i, long long sur_j, int resolution, + long long& sub_i, long long& sub_j) { + if (resolution % 2 == 0) { sub_i = sur_i; sub_j = sur_j; return; } + z7::IVec3D v(sur_i, sur_j, 0); + v.downAp7r(); + z7::IVec2D a(v); + sub_i = a.i(); + sub_j = a.j(); +} + +void quad_xy_to_surrogate_ij_ap7(double quad_x, double quad_y, int resolution, + long long& sur_i, long long& sur_j) { + long long S = ap7_classI_scale(resolution); + long long sub_i, sub_j; + quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), + sub_i, sub_j); + ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, sur_i, sur_j); +} + +void surrogate_ij_to_quad_xy_ap7(long long sur_i, long long sur_j, int resolution, + double& out_quad_x, double& out_quad_y) { + long long S = ap7_classI_scale(resolution); + long long sub_i, sub_j; + ap7_surrogate_to_substrate_ijk(sur_i, sur_j, resolution, sub_i, sub_j); + double cx, cy; + inv_quantize_class1(sub_i, sub_j, cx, cy); + out_quad_x = cx / static_cast(S); + out_quad_y = cy / static_cast(S); +} + +// ============================================================================ +// Public API Implementation +// ============================================================================ + +void icosa_tri_to_quad_xy(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, + int& out_quad, double& out_quad_x, double& out_quad_y) { + if (icosa_triangle_face < 0 || icosa_triangle_face >= 20) { + throw std::runtime_error("icosa_tri_to_quad_xy: icosa_triangle_face must be 0-19"); + } + + const TriangleMapping& mapping = kTriangleMap[icosa_triangle_face]; + + out_quad = mapping.quad; + out_quad_x = icosa_triangle_x; + out_quad_y = icosa_triangle_y; + + // Apply rotation then translation + rotate_60deg_ccw(out_quad_x, out_quad_y, mapping.rotations); + out_quad_x -= mapping.offset_x; + out_quad_y -= mapping.offset_y; +} + +long long get_max_ij(int aperture, int resolution) { + if (resolution <= 0) return 0; + + double factor; + if (aperture == 3) { + factor = std::pow(kSqrt3, resolution); + // Class II (odd res) uses finer substrate + if (resolution % 2 != 0) { + factor *= kSqrt3; + } + } else if (aperture == 4) { + factor = std::pow(2.0, resolution); + } else if (aperture == 7) { + factor = std::pow(std::sqrt(7.0), resolution); + // Class III-I (even res) uses sqrt(7) substrate, Class III-II (odd res) uses sqrt(21) + bool is_class3i = (resolution % 2 == 0); + factor *= is_class3i ? kSqrt7 : kSqrt21; + } else { + return 0; + } + + return static_cast(factor + 1e-9) - 1; +} + +// Handle edge overflow for upper hemisphere quads (1-5) +// Returns true if overflow was handled +inline bool handle_upper_edge(int& quad, long long& i, long long& j, + long long edge_coord, const QuadAdjacency& adj) { + if (j == edge_coord) { + // Top edge + if (i == 0) { + quad = 0; // North pole + i = j = 0; + } else { + quad = adj.up_neighbor; + long long new_j = edge_coord - i; + i = 0; + j = new_j; + } + return true; + } + if (i == edge_coord) { + // Right edge -> right neighbor + quad = adj.right_neighbor; + i = 0; + return true; + } + return false; +} + +// Handle edge overflow for lower hemisphere quads (6-10) +// Returns true if overflow was handled +inline bool handle_lower_edge(int& quad, long long& i, long long& j, + long long edge_coord, const QuadAdjacency& adj) { + if (i == edge_coord) { + // Right edge + if (j == 0) { + quad = 11; // South pole + i = j = 0; + } else { + quad = adj.right_neighbor; + long long new_i = edge_coord - j; + i = new_i; + j = 0; + } + return true; + } + if (j == edge_coord) { + // Top edge -> up neighbor + quad = adj.up_neighbor; + j = 0; + return true; + } + return false; +} + +bool handle_edge_overflow(int& quad, long long& i, long long& j, + int aperture, int resolution) { + long long edge_coord = get_max_ij(aperture, resolution) + 1; + + // Quick exit: not on edge + if (i != edge_coord && j != edge_coord) return false; + + // Polar quads don't overflow + if (quad < 1 || quad > 10) return false; + + const QuadAdjacency& adj = kQuadAdjacency[quad]; + + return adj.is_upper + ? handle_upper_edge(quad, i, j, edge_coord, adj) + : handle_lower_edge(quad, i, j, edge_coord, adj); +} + +void quad_xy_to_ij(int quad, double quad_x, double quad_y, + int aperture, int resolution, + int& out_quad, long long& out_i, long long& out_j) { + + // Aperture 7: exact-integer route. Clean unrotated Class I quantization at + // the substrate scale, DGGRID edgeTable quad canonicalization (an out-of-box + // coordinate belongs to the neighbouring quad), then (odd res) one exact + // aperture-7 coarsen -- yielding the exact resolution-r cell IJK. This keeps + // forward/inverse geometry consistent and replaces the float-rotation Class + // III quantization + handle_edge_overflow, which rounded boundary cells. + if (aperture == 7) { + if (resolution == 0) { + // Resolution 0: one cell per quad plus the two poles. Poles arise + // from the edge-overflow mapping (a point at an icosa vertex), so + // keep that here rather than the z7 hierarchy (empty at res 0). + quantize_class1(quad_x, quad_y, out_i, out_j); + out_quad = quad; + handle_edge_overflow(out_quad, out_i, out_j, 7, 0); + return; + } + long long S = ap7_classI_scale(resolution); + long long sub_i, sub_j; + quantize_class1(quad_x * static_cast(S), quad_y * static_cast(S), + sub_i, sub_j); + out_quad = quad; + dggrid_canonicalize_q2di(S, out_quad, sub_i, sub_j); + ap7_substrate_to_surrogate_ijk(sub_i, sub_j, resolution, out_i, out_j); + return; + } + + // Compute scale factor + double scale; + if (aperture == 3) { + scale = std::pow(kSqrt3, resolution); + } else if (aperture == 4) { + scale = std::pow(2.0, resolution); + } else { + throw std::runtime_error("quad_xy_to_ij: unsupported aperture"); + } + + double scaled_x = quad_x * scale; + double scaled_y = quad_y * scale; + + // Select quantization based on aperture and grid class + if (aperture == 4 || (aperture == 3 && resolution % 2 == 0)) { + // Class I quantization + quantize_class1(scaled_x, scaled_y, out_i, out_j); + } else { + // Class II quantization (aperture 3 odd resolutions) + quantize_class2(scaled_x, scaled_y, out_i, out_j); + } + + out_quad = quad; + handle_edge_overflow(out_quad, out_i, out_j, aperture, resolution); +} + +void icosa_tri_to_quad_ij(int icosa_triangle_face, double icosa_triangle_x, double icosa_triangle_y, + int aperture, int resolution, + int& out_quad, long long& out_i, long long& out_j) { + int quad; + double quad_x, quad_y; + icosa_tri_to_quad_xy(icosa_triangle_face, icosa_triangle_x, icosa_triangle_y, quad, quad_x, quad_y); + quad_xy_to_ij(quad, quad_x, quad_y, aperture, resolution, out_quad, out_i, out_j); +} + +void quad_ij_to_xy(int quad, long long i, long long j, + int aperture, int resolution, + double& out_quad_x, double& out_quad_y) { + + double x, y; + inv_quantize_class1(i, j, x, y); + + // Compute inverse scale accounting for substrate + double scale; + if (aperture == 3) { + bool is_class1 = (resolution % 2 == 0); + scale = is_class1 + ? std::pow(kSqrt3, resolution) + : std::pow(kSqrt3, resolution + 1); // Class II substrate + } else if (aperture == 4) { + scale = std::pow(2.0, resolution); + } else if (aperture == 7) { + // Aperture 7: base scale * substrate multiplier + double base_scale = std::pow(std::sqrt(7.0), resolution); + bool is_class3i = (resolution % 2 == 0); + // Class III-I substrate is sqrt(7)x finer, Class III-II is sqrt(21)x finer + double substrate_mult = is_class3i ? kSqrt7 : kSqrt21; + scale = base_scale * substrate_mult; + } else { + throw std::runtime_error("quad_ij_to_xy: unsupported aperture"); + } + + out_quad_x = x / scale; + out_quad_y = y / scale; +} + +// Shared scale/class computation for the mixed 4/3 substrate: 2x per +// aperture-4 level, sqrt(3)x per aperture-3 level, mirroring +// calc_grid_params_ap43()'s cell-count formula in rcpp_cell.cpp +// (N = 10*4^level*3^(res-level)+2) so the quantized (i,j) match the grid +// that formula describes rather than a pure aperture-3 approximation. +namespace { +void ap43_scale_and_class(int resolution, int mixed_aperture_level, + double& out_scale, bool& out_use_class2) { + double scale = 1.0; + int ap3_count = 0; + for (int r = 1; r <= resolution; r++) { + if (r <= mixed_aperture_level) { + scale *= 2.0; + } else { + scale *= kSqrt3; + ap3_count++; + } + } + out_scale = scale; + out_use_class2 = (ap3_count % 2) == 1; +} +} // anonymous namespace + +void quad_xy_to_ij_ap43(int quad, double quad_x, double quad_y, + int resolution, int mixed_aperture_level, + int& out_quad, long long& out_i, long long& out_j) { + double scale; + bool use_class2; + ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); + + double scaled_x = quad_x * scale; + double scaled_y = quad_y * scale; + + if (use_class2) { + quantize_class2(scaled_x, scaled_y, out_i, out_j); + } else { + quantize_class1(scaled_x, scaled_y, out_i, out_j); + } + + out_quad = quad; + + // Class II quantization internally re-quantizes on a substrate that's + // sqrt(3)x finer than `scale` (see quantize_class2()), so the grid's + // true edge coordinate needs that same factor -- matching + // calc_max_grid_dim_ap43()'s "use_offset" boost in rcpp_cell.cpp. + double edge_scale = use_class2 ? scale * kSqrt3 : scale; + long long edge_coord = static_cast(edge_scale + 1e-9); + + // quantize_class2()'s internal rotate/requantize chain can overshoot the + // predicted edge_coord by a tie-breaking unit near a quad boundary + // (floating-point kSqrt3*kSqrt3 isn't exactly 3.0); handle_upper_edge()/ + // handle_lower_edge() below only match on exact equality, so clamp any + // overshoot back onto the boundary they expect. + if (out_i > edge_coord) out_i = edge_coord; + if (out_j > edge_coord) out_j = edge_coord; + + if ((out_i == edge_coord || out_j == edge_coord) && out_quad >= 1 && out_quad <= 10) { + const QuadAdjacency& adj = kQuadAdjacency[out_quad]; + if (adj.is_upper) { + handle_upper_edge(out_quad, out_i, out_j, edge_coord, adj); + } else { + handle_lower_edge(out_quad, out_i, out_j, edge_coord, adj); + } + } +} + +void quad_ij_to_xy_ap43(int quad, long long i, long long j, + int resolution, int mixed_aperture_level, + double& out_quad_x, double& out_quad_y) { + double x, y; + inv_quantize_class1(i, j, x, y); + + double scale; + bool use_class2; + ap43_scale_and_class(resolution, mixed_aperture_level, scale, use_class2); + double effective_scale = use_class2 ? scale * kSqrt3 : scale; + + out_quad_x = x / effective_scale; + out_quad_y = y / effective_scale; +} + +// ============================================================================ +// vertTable - Derived from First Principles +// ============================================================================ +// +// This table maps (quad, subTriRegion) -> (triNum, trans, rot60) +// +// Each quad is divided into 6 regions based on the hex geometry: +// Region 0: Upper (y > sqrt(3)*x AND y >= -sqrt(3)*x) +// Region 1: Upper-right (y <= sqrt(3)*x AND y >= 0) +// Region 2: Lower-right (y < 0 AND y > -sqrt(3)*x) +// Region 3: Lower (y <= -sqrt(3)*x AND y < sqrt(3)*x) +// Region 4: Lower-left (y >= sqrt(3)*x AND y < 0) +// Region 5: Upper-left (y >= 0 AND y < -sqrt(3)*x) +// +// ============================================================================ +// DERIVATION FROM FIRST PRINCIPLES +// ============================================================================ +// +// The vertTable is the inverse of the triTable. For each (quad, region), we +// need to find which triangle contains that region and what transformation +// brings Quad XY coordinates back to Icosa Triangle coordinates. +// +// ICOSAHEDRON STRUCTURE: +// --------------------- +// 20 triangular faces are numbered 0-19: +// - Faces 0-4: North cap (around vertex 0, touching north pole) +// - Faces 5-9: Upper-middle band (connecting north cap to lower band) +// - Faces 10-14: Lower-middle band (connecting upper band to south cap) +// - Faces 15-19: South cap (around vertex 11, touching south pole) +// +// QUAD STRUCTURE: +// --------------- +// 12 quads (rhombus shapes), each containing 2 triangles: +// - Quad 0: North pole vertex (special - not a rhombus) +// - Quads 1-5: Upper hemisphere, each contains triangles (n-1, n+4) for n=1..5 +// - Quads 6-10: Lower hemisphere, each contains triangles (n+4, n+9) for n=6..10 +// - Quad 11: South pole vertex (special - not a rhombus) +// +// TRIANGLE-TO-QUAD MAPPING (triTable, forward direction): +// ------------------------------------------------------- +// From the triTable, each triangle maps to a quad with a transformation: +// +// Triangle | Quad | Rotation | Translation +// ---------|------|----------|------------- +// 0 | 1 | 1 | (0, 0) <- primary +// 1 | 2 | 1 | (0, 0) <- primary +// 2 | 3 | 1 | (0, 0) <- primary +// 3 | 4 | 1 | (0, 0) <- primary +// 4 | 5 | 1 | (0, 0) <- primary +// 5 | 1 | 4 | (-0.5, -sin60) <- secondary +// 6 | 2 | 4 | (-0.5, -sin60) <- secondary +// 7 | 3 | 4 | (-0.5, -sin60) <- secondary +// 8 | 4 | 4 | (-0.5, -sin60) <- secondary +// 9 | 5 | 4 | (-0.5, -sin60) <- secondary +// 10 | 6 | 1 | (0, 0) <- primary +// 11 | 7 | 1 | (0, 0) <- primary +// 12 | 8 | 1 | (0, 0) <- primary +// 13 | 9 | 1 | (0, 0) <- primary +// 14 | 10 | 1 | (0, 0) <- primary +// 15 | 6 | 4 | (-0.5, -sin60) <- secondary +// 16 | 7 | 4 | (-0.5, -sin60) <- secondary +// 17 | 8 | 4 | (-0.5, -sin60) <- secondary +// 18 | 9 | 4 | (-0.5, -sin60) <- secondary +// 19 | 10 | 4 | (-0.5, -sin60) <- secondary +// +// Forward transform: rotate(rot * 60°) then subtract(trans) +// Inverse transform: add(trans) then rotate(-rot * 60°) +// +// QUAD-TO-TRIANGLE MAPPING (vertTable, inverse direction): +// -------------------------------------------------------- +// For each quad, the 6 regions map to triangles based on adjacency: +// +// Upper quads (1-5) - each contains primary triangle P and secondary S: +// Region 0: Primary triangle P (rot=-1, trans=negate of primary's) +// Region 1: Secondary triangle S (rot=-4, trans=negate of secondary's) +// Region 2: Lower-mid triangle (adjacent via icosahedron edge) +// Region 3: INVALID (extends beyond icosahedron) +// Region 4: Adjacent upper-mid secondary triangle +// Region 5: Previous quad's primary triangle +// +// Lower quads (6-10) - similar structure but mirrored: +// Region 0: Primary triangle (from lower-mid band) +// Region 1: Secondary triangle (from south cap) +// Region 2: Adjacent south cap triangle +// Region 3: Upper quad's secondary triangle +// Region 4: INVALID +// Region 5: Adjacent upper-mid secondary triangle +// +// ADJACENCY DERIVATION: +// --------------------- +// From icosahedron face definition: +// faces[20][3] = { +// {0,1,2},{0,2,3},{0,3,4},{0,4,5},{0,5,1}, // 0-4: North cap +// {6,2,1},{7,3,2},{8,4,3},{9,5,4},{10,1,5}, // 5-9: Upper-mid band +// {2,6,7},{3,7,8},{4,8,9},{5,9,10},{1,10,6}, // 10-14: Lower-mid band +// {11,7,6},{11,8,7},{11,9,8},{11,10,9},{11,6,10} // 15-19: South cap +// } +// +// Two faces are adjacent if they share 2 vertices. For each quad region, +// the adjacent triangle is determined by which face shares the edge +// corresponding to that region's direction. +// +// For quad q (1-5): +// - Region 0 → triangle (q-1): primary triangle of this quad +// - Region 1 → triangle (q+4): secondary triangle of this quad +// - Region 2 → triangle (q+9): lower-mid band (shares edge going southeast) +// - Region 3 → INVALID (no icosahedron face in this direction) +// - Region 4 → triangle ((q+3)%5+5): previous quad's secondary +// - Region 5 → triangle ((q-2+5)%5): next quad's primary +// +// For quad q (6-10): +// - Region 0 → triangle (q+4): lower-mid band primary +// - Region 1 → triangle (q+9): south cap secondary +// - Region 2 → triangle ((q-6+4)%5+15): adjacent south cap +// - Region 3 → triangle (q-6+10): this quad's lower-mid adjacent +// - Region 4 → INVALID +// - Region 5 → triangle ((q-6+4)%5+5): upper-mid secondary +// +// TRANSFORMATION DERIVATION: +// -------------------------- +// The inverse transformation parameters are computed as: +// - rot60: Negate the forward rotation +// - trans: The translation needed to move from Quad XY back to Icosa Triangle +// +// For a primary triangle (forward: rot=1, trans=(0,0)): +// Inverse: rot=-1, stored as 1 with sign applied during usage +// +// For a secondary triangle (forward: rot=4, trans=(-0.5,-sin60)): +// Inverse: rot=-4, trans is negated after rotation adjustment +// +// Cross-quad adjacencies require additional transformations based on how +// the triangles are oriented relative to each other. +// +// ============================================================================ + +struct VertTriVals { + int triNum; // Output triangle number + double trans_x; // Translation x (added to Quad XY before rotation) + double trans_y; // Translation y (added to Quad XY before rotation) + int rot60; // Number of 60-degree rotations (multiply by -60 for actual rotation) + bool keep; // Whether to keep this vertex +}; + +// vertTable[quad][subTri] - Derived from icosahedron geometry +// +// The derivation uses these key relationships: +// +// 1. Primary triangles of quads 1-5 are faces 0-4 (north cap) +// 2. Secondary triangles of quads 1-5 are faces 5-9 (upper-mid band) +// 3. Primary triangles of quads 6-10 are faces 10-14 (lower-mid band) +// 4. Secondary triangles of quads 6-10 are faces 15-19 (south cap) +// +// 5. Each region maps to an adjacent triangle with a specific transformation: +// - Region 0: The "upper" direction in Quad XY space +// - Region 1: The "upper-right" direction (60° clockwise from up) +// - Region 2: The "lower-right" direction (120° clockwise from up) +// - Region 3: The "lower" direction (180° from up) +// - Region 4: The "lower-left" direction (240° clockwise from up) +// - Region 5: The "upper-left" direction (300° clockwise from up) +// +// 6. The transformations are computed to reverse the forward triTable mapping +// while accounting for the hexagonal geometry. +// +static const VertTriVals kVertTable[12][6] = { + // ======================================================================== + // Quad 0 (North pole vertex) + // ======================================================================== + // The north pole (vertex 0) is surrounded by triangles 0-4. + // This is a special case where 5 triangles meet at a point. + // The 6 regions map to these 5 triangles with one invalid region. + // + // From vertex 0, going around counter-clockwise: + // Triangle 0: shares edge with triangles 4 and 1 + // Triangle 1: shares edge with triangles 0 and 2 + // Triangle 2: shares edge with triangles 1 and 3 + // Triangle 3: shares edge with triangles 2 and 4 + // Triangle 4: shares edge with triangles 3 and 0 + // + // Region assignments (empirically verified): + // Region 0 → Triangle 1 (rot=3) + // Region 1 → Triangle 0 (rot=2) + // Region 2 → Triangle 4 (rot=1) + // Region 3 → INVALID (pentagon vertex, no 6th triangle) + // Region 4 → Triangle 3 (rot=-1) + // Region 5 → Triangle 2 (rot=-2) + { + { 1, -0.5, -kSin60, 3, true}, // Region 0 → tri 1 + { 0, -1.0, 0.0, 2, true}, // Region 1 → tri 0 + { 4, -0.5, kSin60, 1, true}, // Region 2 → tri 4 + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 3, 1.0, 0.0, -1, true}, // Region 4 → tri 3 + { 2, 0.5, -kSin60, -2, true} // Region 5 → tri 2 + }, + + // ======================================================================== + // Quads 1-5 (Upper hemisphere) + // ======================================================================== + // Each quad q contains: + // - Primary triangle: (q-1) from north cap (faces 0-4) + // - Secondary triangle: (q+4) from upper-mid band (faces 5-9) + // + // The primary triangle transformation is: rot=1, trans=(0,0) + // The secondary triangle transformation is: rot=4, trans=(-0.5,-sin60) + // + // Inverse transformations: + // - For primary: add (0,0), rotate -1*60° = rotate(-60°) + // - For secondary: add (0.5,sin60) rotated, then rotate -4*60° + // + // Cross-quad adjacencies (computed from icosahedron edge sharing): + // Region 2: Lower-mid band triangle (q+9) with special transform + // Region 4: Previous quad's secondary triangle + // Region 5: Next quad's primary triangle (wrapping around) + // + // Quad 1: primary=tri0, secondary=tri5 + { + { 0, 0.0, 0.0, 1, true}, // Region 0 → tri 0 (primary) + { 5, -0.5, -kSin60, 4, true}, // Region 1 → tri 5 (secondary) + {14, -0.5, kSin60, 1, true}, // Region 2 → tri 14 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 9, 0.0, 0.0, 3, true}, // Region 4 → tri 9 (quad5's secondary) + { 4, 1.0, 0.0, 0, true} // Region 5 → tri 4 (quad5's primary) + }, + // Quad 2: primary=tri1, secondary=tri6 + { + { 1, 0.0, 0.0, 1, true}, // Region 0 → tri 1 (primary) + { 6, -0.5, -kSin60, 4, true}, // Region 1 → tri 6 (secondary) + {10, -0.5, kSin60, 1, true}, // Region 2 → tri 10 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 5, 0.0, 0.0, 3, true}, // Region 4 → tri 5 (quad1's secondary) + { 0, 1.0, 0.0, 0, true} // Region 5 → tri 0 (quad1's primary) + }, + // Quad 3: primary=tri2, secondary=tri7 + { + { 2, 0.0, 0.0, 1, true}, // Region 0 → tri 2 (primary) + { 7, -0.5, -kSin60, 4, true}, // Region 1 → tri 7 (secondary) + {11, -0.5, kSin60, 1, true}, // Region 2 → tri 11 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 6, 0.0, 0.0, 3, true}, // Region 4 → tri 6 (quad2's secondary) + { 1, 1.0, 0.0, 0, true} // Region 5 → tri 1 (quad2's primary) + }, + // Quad 4: primary=tri3, secondary=tri8 + { + { 3, 0.0, 0.0, 1, true}, // Region 0 → tri 3 (primary) + { 8, -0.5, -kSin60, 4, true}, // Region 1 → tri 8 (secondary) + {12, -0.5, kSin60, 1, true}, // Region 2 → tri 12 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 7, 0.0, 0.0, 3, true}, // Region 4 → tri 7 (quad3's secondary) + { 2, 1.0, 0.0, 0, true} // Region 5 → tri 2 (quad3's primary) + }, + // Quad 5: primary=tri4, secondary=tri9 + { + { 4, 0.0, 0.0, 1, true}, // Region 0 → tri 4 (primary) + { 9, -0.5, -kSin60, 4, true}, // Region 1 → tri 9 (secondary) + {13, -0.5, kSin60, 1, true}, // Region 2 → tri 13 (lower-mid, adjacent) + {-1, -0.5, kSin60, 1, false}, // Region 3 → INVALID + { 8, 0.0, 0.0, 3, true}, // Region 4 → tri 8 (quad4's secondary) + { 3, 1.0, 0.0, 0, true} // Region 5 → tri 3 (quad4's primary) + }, + + // ======================================================================== + // Quads 6-10 (Lower hemisphere) + // ======================================================================== + // Each quad q contains: + // - Primary triangle: (q+4) from lower-mid band (faces 10-14) + // - Secondary triangle: (q+9) from south cap (faces 15-19) + // + // Lower hemisphere quads have different adjacency patterns: + // Region 0: Primary triangle (lower-mid band) + // Region 1: Secondary triangle (south cap) + // Region 2: Adjacent south cap triangle (wrapping) + // Region 3: Upper quad's lower-mid triangle (cross-hemisphere) + // Region 4: INVALID + // Region 5: Upper quad's secondary triangle + // + // Quad 6: primary=tri10, secondary=tri15 + { + {10, 0.0, 0.0, 1, true}, // Region 0 → tri 10 (primary) + {15, -0.5, -kSin60, 4, true}, // Region 1 → tri 15 (secondary) + {19, 0.0, 0.0, -1, true}, // Region 2 → tri 19 (adjacent south cap) + {14, -0.5, kSin60, 2, true}, // Region 3 → tri 14 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 5, 0.5, -kSin60, 4, true} // Region 5 → tri 5 (upper secondary) + }, + // Quad 7: primary=tri11, secondary=tri16 + { + {11, 0.0, 0.0, 1, true}, // Region 0 → tri 11 (primary) + {16, -0.5, -kSin60, 4, true}, // Region 1 → tri 16 (secondary) + {15, 0.0, 0.0, -1, true}, // Region 2 → tri 15 (adjacent south cap) + {10, -0.5, kSin60, 2, true}, // Region 3 → tri 10 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 6, 0.5, -kSin60, 4, true} // Region 5 → tri 6 (upper secondary) + }, + // Quad 8: primary=tri12, secondary=tri17 + { + {12, 0.0, 0.0, 1, true}, // Region 0 → tri 12 (primary) + {17, -0.5, -kSin60, 4, true}, // Region 1 → tri 17 (secondary) + {16, 0.0, 0.0, -1, true}, // Region 2 → tri 16 (adjacent south cap) + {11, -0.5, kSin60, 2, true}, // Region 3 → tri 11 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 7, 0.5, -kSin60, 4, true} // Region 5 → tri 7 (upper secondary) + }, + // Quad 9: primary=tri13, secondary=tri18 + { + {13, 0.0, 0.0, 1, true}, // Region 0 → tri 13 (primary) + {18, -0.5, -kSin60, 4, true}, // Region 1 → tri 18 (secondary) + {17, 0.0, 0.0, -1, true}, // Region 2 → tri 17 (adjacent south cap) + {12, -0.5, kSin60, 2, true}, // Region 3 → tri 12 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 8, 0.5, -kSin60, 4, true} // Region 5 → tri 8 (upper secondary) + }, + // Quad 10: primary=tri14, secondary=tri19 + { + {14, 0.0, 0.0, 1, true}, // Region 0 → tri 14 (primary) + {19, -0.5, -kSin60, 4, true}, // Region 1 → tri 19 (secondary) + {18, 0.0, 0.0, -1, true}, // Region 2 → tri 18 (adjacent south cap) + {13, -0.5, kSin60, 2, true}, // Region 3 → tri 13 (lower-mid, cross) + {-1, -0.5, kSin60, 1, false}, // Region 4 → INVALID + { 9, 0.5, -kSin60, 4, true} // Region 5 → tri 9 (upper secondary) + }, + + // ======================================================================== + // Quad 11 (South pole vertex) + // ======================================================================== + // The south pole (vertex 11) is surrounded by triangles 15-19. + // This is a special case where 5 triangles meet at a point. + // The 6 regions map to these 5 triangles with one invalid region. + // + // From vertex 11, going around counter-clockwise: + // Triangle 15: shares edge with triangles 19 and 16 + // Triangle 16: shares edge with triangles 15 and 17 + // Triangle 17: shares edge with triangles 16 and 18 + // Triangle 18: shares edge with triangles 17 and 19 + // Triangle 19: shares edge with triangles 18 and 15 + // + // Region assignments (empirically verified): + // Region 0 → Triangle 17 (rot=3) + // Region 1 → Triangle 18 (rot=2) + // Region 2 → Triangle 19 (rot=1) + // Region 3 → Triangle 15 (rot=0) + // Region 4 → INVALID (pentagon vertex, no 6th triangle) + // Region 5 → Triangle 16 (rot=-2) + { + {17, -0.5, -kSin60, 3, true}, // Region 0 → tri 17 + {18, -1.0, 0.0, 2, true}, // Region 1 → tri 18 + {19, -0.5, kSin60, 1, true}, // Region 2 → tri 19 + {15, 0.5, kSin60, 0, true}, // Region 3 → tri 15 + {-1, 0.0, 0.0, 0, false}, // Region 4 → INVALID + {16, 0.5, -kSin60, -2, true} // Region 5 → tri 16 + } +}; + +// ============================================================================ +// Sub-triangle Region Detection +// ============================================================================ +// +// Divides the Quad XY coordinate space into 6 wedge-shaped regions emanating +// from the origin. The boundaries are lines at angles 0°, 60°, 120°, 180°, +// 240°, 300° from the positive x-axis. The key boundary is y = ±sqrt(3)*x. +// +// Region 0 (Upper) +// /\ +// Reg 5 / \ Reg 1 +// -----+----+----- +// Reg 4 \ / Reg 2 +// \/ +// Region 3 (Lower) +// +// Each region maps to a different triangle in the icosahedron. + +// Check if point is at origin (within tolerance) +inline bool is_origin(double x, double y, double tol) { + return std::fabs(x) <= tol && std::fabs(y) <= tol; +} + +// Compute which of 6 sub-regions a Quad XY point falls into +// Uses 6-way wedge classification based on y = ±sqrt(3)*x boundaries +static int compute_subtriangle(double x, double y) { + constexpr double tol = 1e-15; + + // Origin -> Region 1 (center/upper-right by convention) + if (is_origin(x, y, tol)) return 1; + + // Pre-compute boundary lines: y = ±sqrt(3)*x with tolerance + const double xs = kSqrt3 * x; + const double xs_plus = xs + tol; // y = sqrt(3)*x + tol + const double xs_minus = xs - tol; // y = sqrt(3)*x - tol + const double neg_xs_plus = -xs + tol; // y = -sqrt(3)*x + tol + const double neg_xs_minus = -xs - tol; // y = -sqrt(3)*x - tol + + // Region 0: Upper (above both diagonal lines) + if (y >= neg_xs_minus && y > xs_plus) return 0; + + // Region 1: Upper-right (below y=sqrt(3)*x, above y=0) + if (y <= xs_plus && y >= -tol) return 1; + + // Region 2: Lower-right (below y=0, above y=-sqrt(3)*x) + if (y < -tol && y > neg_xs_plus) return 2; + + // Region 3: Lower (below both diagonal lines) + if (y <= neg_xs_plus && y < xs_minus) return 3; + + // Region 4: Lower-left (above y=sqrt(3)*x, below y=0) + if (y >= xs_minus && y < -tol) return 4; + + // Region 5: Upper-left (above y=0, below y=-sqrt(3)*x) + if (y >= -tol && y < neg_xs_minus) return 5; + + // Fallback (should not occur for valid quad coordinates) + return 1; +} + +// Try to convert quad XY to icosa triangle coords. Returns true on success, +// false if the point is in an invalid region (e.g., outside the valid quad bounds). +bool try_quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, + int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { + if (quad < kMinQuad || quad > kMaxQuad) { + throw std::invalid_argument("try_quad_xy_to_icosa_tri: quad must be between 0 and 11"); + } + + // Detect which of 6 sub-regions the point falls into + int subTri = compute_subtriangle(quad_x, quad_y); + + // Look up transformation from vertTable + const VertTriVals& triVal = kVertTable[quad][subTri]; + + if (!triVal.keep || triVal.triNum < 0) { + // This region maps to an invalid/dropped vertex + return false; + } + + out_icosa_triangle_face = triVal.triNum; + + // Apply inverse transformation: + // coord += trans + // coord.rotate(rot60 * -60.0) // rotate by -60*rot60 degrees CCW + out_icosa_triangle_x = quad_x + triVal.trans_x; + out_icosa_triangle_y = quad_y + triVal.trans_y; + + // Rotate: rot60 * -60 degrees = -60 * rot60 degrees CCW + // Which is the same as 60 * rot60 degrees CW + // If rot60=4, rotate -240 degrees CCW = 120 degrees CW = -2 rotations of 60deg CCW + rotate_60deg_ccw(out_icosa_triangle_x, out_icosa_triangle_y, -triVal.rot60); + return true; +} + +void quad_xy_to_icosa_tri(int quad, double quad_x, double quad_y, + int& out_icosa_triangle_face, double& out_icosa_triangle_x, double& out_icosa_triangle_y) { + if (!try_quad_xy_to_icosa_tri(quad, quad_x, quad_y, out_icosa_triangle_face, out_icosa_triangle_x, out_icosa_triangle_y)) { + throw std::runtime_error("quad_xy_to_icosa_tri: point in invalid region"); + } +} + +} // namespace hexify diff --git a/src/grid_math.h b/src/grid_math.h index ec01a9c..7b0851e 100644 --- a/src/grid_math.h +++ b/src/grid_math.h @@ -1,429 +1,429 @@ -// grid_math.h - Shared hexagonal grid mathematics -// -// This module provides the fundamental mathematical operations for hexagonal -// discrete global grid systems (DGGS). It consolidates operations that were -// previously duplicated across aperture-specific files. -// -// ============================================================================ -// HEXAGON ROTATION CLASSES -// ============================================================================ -// -// Hexagonal grids come in different orientations called "Rotation Classes" -// (terminology from Sahr et al. 2003): -// -// ROTATION CLASS I (0-degree, flat-top) -// -------------------------------------- -// - Standard orientation (0 degrees) -// - Used by aperture-4 always -// - Flat edge on top -// -// ROTATION CLASS II (30-degree, pointy-top) -// ------------------------------------------ -// - Rotated 30 degrees from Rotation Class I -// - Alternates with Rotation Class I in aperture-3 -// - Pointed vertex on top -// -// ROTATION CLASS III (Aperture-7 only) -// ------------------------------------- -// Rotated by arctan(sqrt(3/7)) = ~19.1 degrees from the base orientation. -// Two variants alternate by resolution: -// - Rotation Class III-A (even res): Rotation Class I + 19.1 deg = ~19 deg -// - Rotation Class III-B (odd res): Rotation Class II + 19.1 deg = ~49 deg -// -// ============================================================================ -// COORDINATE SYSTEMS -// ============================================================================ -// -// CUBE COORDINATES (q, r, s) -// -------------------------- -// Three-axis system where q + r + s = 0. Provides elegant nearest-neighbor -// rounding via the "round and fix" algorithm. Used internally for quantization. -// -// +s -// | -// | -// +q ----+---- -q -// | -// | -// -s -// -// OFFSET COORDINATES (i, j) -// ------------------------- -// Two-axis system output by quantization. Maps directly to cell indices. -// In our implementation: i = q, j = r (from cube coordinates). -// -// ============================================================================ -// SURROGATE-SUBSTRATE PATTERN -// ============================================================================ -// -// Non-Rotation-Class-I grids use a "surrogate-substrate" quantization pattern: -// -// 1. ROTATE input point to align with a "surrogate" Rotation Class I grid -// 2. QUANTIZE in the surrogate grid (using Rotation Class I math) -// 3. GET CENTER of the surrogate cell -// 4. ROTATE BACK to original orientation -// 5. SCALE UP to a finer "substrate" grid and re-quantize -// -// This pattern produces coordinates compatible with hierarchical ISEA grids. -// -// Scale factors by rotation class: -// - Rotation Class II: sqrt(3) = ~1.732 (from Rotation Class I surrogate) -// - Rotation Class III-A: sqrt(7) = ~2.646 (aperture-7, even resolutions) -// - Rotation Class III-B: sqrt(21) = ~4.583 (aperture-7, odd resolutions) -// -// Copyright (c) 2024 hexify authors. MIT License. - -#ifndef HEXIFY_GRID_MATH_H -#define HEXIFY_GRID_MATH_H - -#include "cube_coordinates.h" -#include "constants.h" -#include - -namespace hexify { - -// ============================================================================ -// Rotation Utilities -// ============================================================================ - -/** - * Rotate a 2D point by an angle (in radians). - * - * @param x Input/output X coordinate - * @param y Input/output Y coordinate - * @param angle Rotation angle in radians (positive = counter-clockwise) - */ -inline void rotate_point(double& x, double& y, double angle_rad) { - double c = std::cos(angle_rad); - double s = std::sin(angle_rad); - double new_x = x * c - y * s; - double new_y = x * s + y * c; - x = new_x; - y = new_y; -} - -/** - * Rotate a 2D point using pre-computed sin/cos values. - * More efficient when the same rotation is applied many times. - * - * @param x Input/output X coordinate - * @param y Input/output Y coordinate - * @param cos_a Cosine of the rotation angle - * @param sin_a Sine of the rotation angle - */ -inline void rotate_point_precomputed(double& x, double& y, - double cos_a, double sin_a) { - double new_x = x * cos_a - y * sin_a; - double new_y = x * sin_a + y * cos_a; - x = new_x; - y = new_y; -} - -/** - * Rotate a 2D point by the inverse angle (negate sin). - * Used for "rotate back" operations in surrogate-substrate pattern. - */ -inline void rotate_point_inverse(double& x, double& y, - double cos_a, double sin_a) { - double new_x = x * cos_a + y * sin_a; - double new_y = -x * sin_a + y * cos_a; - x = new_x; - y = new_y; -} - -// ============================================================================ -// Rotation Class I (0-Degree, Flat-Top) Hexagon Quantization -// ============================================================================ -// -// This is the fundamental quantization algorithm. All other rotation classes -// use this as a building block via the surrogate-substrate pattern. - -/** - * Quantize a point to the nearest Rotation Class I (0-degree, flat-top) hexagon. - * - * Algorithm: - * 1. Convert Cartesian (x, y) to cube coordinates (q, r, s) - * 2. Round each cube coordinate to nearest integer - * 3. Fix rounding to maintain q + r + s = 0 constraint - * 4. Output offset coordinates (i, j) = (q, r) - * - * @param x X coordinate in hex grid space - * @param y Y coordinate in hex grid space - * @param out_i Output: column index (q from cube coords) - * @param out_j Output: row index (r from cube coords) - */ -inline void quantize_rotation_classI(double x, double y, - long long& out_i, long long& out_j) { - // Guard against NaN/Inf inputs to avoid UB in CubeCoord::round_to_nearest()'s - // std::llround() call. Matches coordinate_transforms.cpp::quantize_class1. - if (!std::isfinite(x) || !std::isfinite(y)) { - out_i = 0; - out_j = 0; - return; - } - - // Convert to cube coordinates using flat-top layout - CubeCoord cube = cartesian_to_cube(x, y, kSqrt3); - - // Round to nearest hex center (maintains q + r + s = 0) - cube.round_to_nearest(); - - // Extract offset coordinates - out_i = static_cast(cube.q); - out_j = static_cast(cube.r); -} - -/** - * Get the Cartesian center of a Rotation Class I (0-degree, flat-top) hexagon. - * - * @param i Column index - * @param j Row index - * @param out_x Output: X coordinate of cell center - * @param out_y Output: Y coordinate of cell center - */ -inline void center_rotation_classI(long long i, long long j, - double& out_x, double& out_y) { - cube_to_cartesian(static_cast(i), static_cast(j), - out_x, out_y, kSin60); -} - -// ============================================================================ -// Rotation Class II (30-Degree, Pointy-Top) Hexagon Quantization -// ============================================================================ -// -// Rotation Class II hexagons are rotated 30 degrees from Rotation Class I. -// Uses the surrogate-substrate pattern. - -/** - * Quantize a point to the nearest Rotation Class II (30-degree, pointy-top) hexagon. - * - * Uses surrogate-substrate pattern: - * 1. Rotate by -30 deg to align with Rotation Class I surrogate - * 2. Quantize in Rotation Class I grid - * 3. Get surrogate center, rotate back by +30 deg - * 4. Scale by sqrt(3) to substrate, re-quantize in Rotation Class I grid - * - * @param x X coordinate in hex grid space - * @param y Y coordinate in hex grid space - * @param out_i Output: column index in substrate coordinates - * @param out_j Output: row index in substrate coordinates - */ -inline void quantize_rotation_classII(double x, double y, - long long& out_i, long long& out_j) { - // Pre-computed rotation constants for -30 degrees - constexpr double cos_neg30 = kCos30; // cos(-30) = cos(30) - constexpr double sin_neg30 = -kSin30; // sin(-30) = -sin(30) = -0.5 - - // Step 1: Rotate to Rotation Class I surrogate frame (-30 degrees) - double sur_x = x * cos_neg30 - y * sin_neg30; - double sur_y = x * sin_neg30 + y * cos_neg30; - - // Step 2: Quantize in Rotation Class I surrogate - long long sur_i, sur_j; - quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); - - // Step 3: Get surrogate center - double cen_x, cen_y; - center_rotation_classI(sur_i, sur_j, cen_x, cen_y); - - // Step 4: Rotate center back to original frame (+30 degrees) - // Inverse rotation: cos same, sin negated - double back_x = cen_x * cos_neg30 + cen_y * sin_neg30; - double back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; - - // Step 5: Scale to substrate (sqrt(3) finer) and re-quantize - quantize_rotation_classI(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); -} - -// ============================================================================ -// Rotation Class III (Aperture-7) Hexagon Quantization -// ============================================================================ -// -// Aperture-7 uses hexagons rotated by arctan(sqrt(3/7)) = ~19.1 degrees. -// Two variants alternate by resolution: -// - Rotation Class III-A (~19 deg, even res): Rotation Class I + 19.1 deg -// - Rotation Class III-B (~49 deg, odd res): Rotation Class II + 19.1 deg - -// Pre-computed constants for ~19.1 degree rotation -namespace detail { - // arctan(sqrt(3)/5) in radians - constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; - constexpr double kCos19 = 0.9449111825230680440492389263705078; // cos(19.106...°) - constexpr double kSin19 = 0.3273268353539885718950317563490135; // sin(19.106...°) -} - -/** - * Quantize to Rotation Class III-A (~19-degree) hexagon (aperture-7, even resolutions). - * - * Surrogate: Rotation Class I grid rotated by -19.1 degrees - * Substrate: sqrt(7) times finer than surrogate - */ -inline void quantize_rotation_classIII_A(double x, double y, - long long& out_i, long long& out_j) { - using namespace detail; - - // Step 1: Rotate to Rotation Class I surrogate frame (-19.1 degrees) - double sur_x = x * kCos19 + y * kSin19; // cos(-a) = cos(a) - double sur_y = -x * kSin19 + y * kCos19; // sin(-a) = -sin(a) - - // Step 2: Quantize in Rotation Class I surrogate - long long sur_i, sur_j; - quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); - - // Step 3: Get surrogate center - double cen_x, cen_y; - center_rotation_classI(sur_i, sur_j, cen_x, cen_y); - - // Step 4: Rotate back to original frame (+19.1 degrees) - double back_x = cen_x * kCos19 - cen_y * kSin19; - double back_y = cen_x * kSin19 + cen_y * kCos19; - - // Step 5: Scale to substrate (sqrt(7) finer) and re-quantize - quantize_rotation_classI(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); -} - -/** - * Quantize to Rotation Class III-B (~49-degree) hexagon (aperture-7, odd resolutions). - * - * Surrogate: Rotation Class II grid rotated by -19.1 degrees - * (equivalent to Rotation Class I grid rotated by -49.1 degrees) - * Substrate: sqrt(21) times finer than surrogate - */ -inline void quantize_rotation_classIII_B(double x, double y, - long long& out_i, long long& out_j) { - using namespace detail; - - // Step 1: Rotate to surrogate frame (-19.1 degrees) - double sur_x = x * kCos19 + y * kSin19; - double sur_y = -x * kSin19 + y * kCos19; - - // Step 2: The surrogate is Rotation Class II (rotated 30 deg from Rotation Class I). - // To quantize, first rotate to Rotation Class I orientation. - constexpr double cos_neg30 = kCos30; - constexpr double sin_neg30 = -0.5; - - double rotated_x = sur_x * cos_neg30 - sur_y * sin_neg30; - double rotated_y = sur_x * sin_neg30 + sur_y * cos_neg30; - - // Step 3: Quantize in Rotation Class I grid - long long sur_i, sur_j; - quantize_rotation_classI(rotated_x, rotated_y, sur_i, sur_j); - - // Step 4: Get Rotation Class I center - double cen_x, cen_y; - center_rotation_classI(sur_i, sur_j, cen_x, cen_y); - - // Step 5: Rotate back to Rotation Class II orientation (+30 degrees) - double rotated_back_x = cen_x * cos_neg30 + cen_y * sin_neg30; - double rotated_back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; - - // Step 6: Rotate back to original frame (+19.1 degrees) - double back_x = rotated_back_x * kCos19 - rotated_back_y * kSin19; - double back_y = rotated_back_x * kSin19 + rotated_back_y * kCos19; - - // Step 7: Scale to substrate (sqrt(21) finer) and re-quantize - quantize_rotation_classI(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); -} - -// ============================================================================ -// Hexagon Corner Generation -// ============================================================================ - -/** - * Generate the 6 corners of a hexagon given its center and radius. - * - * @param cx Center X coordinate - * @param cy Center Y coordinate - * @param radius Distance from center to each corner - * @param rotation_deg Rotation offset in degrees (0 = flat-top, 30 = pointy-top) - * @param out_x Output array of 6 X coordinates - * @param out_y Output array of 6 Y coordinates - */ -inline void generate_hex_corners(double cx, double cy, double radius, - double rotation_deg, - double* out_x, double* out_y) { - double rotation_rad = rotation_deg * kDegToRad; - - // Generate 6 vertices starting at top, counter-clockwise - for (int k = 0; k < 6; ++k) { - double angle = kPiOver2 + rotation_rad + k * kPiOver3; - out_x[k] = cx + radius * std::cos(angle); - out_y[k] = cy + radius * std::sin(angle); - } -} - -// ============================================================================ -// Aperture Scale Factors -// ============================================================================ - -/** - * Get the cumulative scale factor for a given aperture and resolution. - * - * @param aperture Aperture type (3, 4, or 7) - * @param resolution Grid resolution level - * @return Scale factor to multiply coordinates by - */ -inline double aperture_scale(int aperture, int resolution) { - switch (aperture) { - case 3: return std::pow(kSqrt3, resolution); - case 4: return std::pow(2.0, resolution); - case 7: return std::pow(kSqrt7, resolution); - default: return 1.0; - } -} - -/** - * Get the substrate scale multiplier for the current orientation class. - * - * @param aperture Aperture type - * @param resolution Grid resolution level - * @return Additional scale factor for substrate coordinates - */ -inline double substrate_multiplier(int aperture, int resolution) { - bool is_even = (resolution % 2 == 0); - - switch (aperture) { - case 3: - // Rotation Class I (even): no extra scale - // Rotation Class II (odd): sqrt(3) substrate - return is_even ? 1.0 : kSqrt3; - case 4: - // Always Rotation Class I, no extra scale - return 1.0; - case 7: - // Rotation Class III-A (even): sqrt(7) substrate - // Rotation Class III-B (odd): sqrt(21) substrate - return is_even ? kSqrt7 : kSqrt21; - default: - return 1.0; - } -} - -/** - * Get the rotation offset in degrees for hexagon corners. - * - * @param aperture Aperture type - * @param resolution Grid resolution level - * @return Rotation offset in degrees - */ -inline double corner_rotation_deg(int aperture, int resolution) { - bool is_even = (resolution % 2 == 0); - - switch (aperture) { - case 3: - // Rotation Class I: 0 deg, Rotation Class II: 30 deg - return is_even ? 0.0 : 30.0; - case 4: - // Always Rotation Class I - return 0.0; - case 7: - // Rotation Class III-A: ~19.1 deg, Rotation Class III-B: ~49.1 deg - return is_even ? kAp7RotDeg : (kAp7RotDeg + 30.0); - default: - return 0.0; - } -} - -} // namespace hexify - -#endif // HEXIFY_GRID_MATH_H +// grid_math.h - Shared hexagonal grid mathematics +// +// This module provides the fundamental mathematical operations for hexagonal +// discrete global grid systems (DGGS). It consolidates operations that were +// previously duplicated across aperture-specific files. +// +// ============================================================================ +// HEXAGON ROTATION CLASSES +// ============================================================================ +// +// Hexagonal grids come in different orientations called "Rotation Classes" +// (terminology from Sahr et al. 2003): +// +// ROTATION CLASS I (0-degree, flat-top) +// -------------------------------------- +// - Standard orientation (0 degrees) +// - Used by aperture-4 always +// - Flat edge on top +// +// ROTATION CLASS II (30-degree, pointy-top) +// ------------------------------------------ +// - Rotated 30 degrees from Rotation Class I +// - Alternates with Rotation Class I in aperture-3 +// - Pointed vertex on top +// +// ROTATION CLASS III (Aperture-7 only) +// ------------------------------------- +// Rotated by arctan(sqrt(3/7)) = ~19.1 degrees from the base orientation. +// Two variants alternate by resolution: +// - Rotation Class III-A (even res): Rotation Class I + 19.1 deg = ~19 deg +// - Rotation Class III-B (odd res): Rotation Class II + 19.1 deg = ~49 deg +// +// ============================================================================ +// COORDINATE SYSTEMS +// ============================================================================ +// +// CUBE COORDINATES (q, r, s) +// -------------------------- +// Three-axis system where q + r + s = 0. Provides elegant nearest-neighbor +// rounding via the "round and fix" algorithm. Used internally for quantization. +// +// +s +// | +// | +// +q ----+---- -q +// | +// | +// -s +// +// OFFSET COORDINATES (i, j) +// ------------------------- +// Two-axis system output by quantization. Maps directly to cell indices. +// In our implementation: i = q, j = r (from cube coordinates). +// +// ============================================================================ +// SURROGATE-SUBSTRATE PATTERN +// ============================================================================ +// +// Non-Rotation-Class-I grids use a "surrogate-substrate" quantization pattern: +// +// 1. ROTATE input point to align with a "surrogate" Rotation Class I grid +// 2. QUANTIZE in the surrogate grid (using Rotation Class I math) +// 3. GET CENTER of the surrogate cell +// 4. ROTATE BACK to original orientation +// 5. SCALE UP to a finer "substrate" grid and re-quantize +// +// This pattern produces coordinates compatible with hierarchical ISEA grids. +// +// Scale factors by rotation class: +// - Rotation Class II: sqrt(3) = ~1.732 (from Rotation Class I surrogate) +// - Rotation Class III-A: sqrt(7) = ~2.646 (aperture-7, even resolutions) +// - Rotation Class III-B: sqrt(21) = ~4.583 (aperture-7, odd resolutions) +// +// Copyright (c) 2024 hexify authors. MIT License. + +#ifndef HEXIFY_GRID_MATH_H +#define HEXIFY_GRID_MATH_H + +#include "cube_coordinates.h" +#include "constants.h" +#include + +namespace hexify { + +// ============================================================================ +// Rotation Utilities +// ============================================================================ + +/** + * Rotate a 2D point by an angle (in radians). + * + * @param x Input/output X coordinate + * @param y Input/output Y coordinate + * @param angle Rotation angle in radians (positive = counter-clockwise) + */ +inline void rotate_point(double& x, double& y, double angle_rad) { + double c = std::cos(angle_rad); + double s = std::sin(angle_rad); + double new_x = x * c - y * s; + double new_y = x * s + y * c; + x = new_x; + y = new_y; +} + +/** + * Rotate a 2D point using pre-computed sin/cos values. + * More efficient when the same rotation is applied many times. + * + * @param x Input/output X coordinate + * @param y Input/output Y coordinate + * @param cos_a Cosine of the rotation angle + * @param sin_a Sine of the rotation angle + */ +inline void rotate_point_precomputed(double& x, double& y, + double cos_a, double sin_a) { + double new_x = x * cos_a - y * sin_a; + double new_y = x * sin_a + y * cos_a; + x = new_x; + y = new_y; +} + +/** + * Rotate a 2D point by the inverse angle (negate sin). + * Used for "rotate back" operations in surrogate-substrate pattern. + */ +inline void rotate_point_inverse(double& x, double& y, + double cos_a, double sin_a) { + double new_x = x * cos_a + y * sin_a; + double new_y = -x * sin_a + y * cos_a; + x = new_x; + y = new_y; +} + +// ============================================================================ +// Rotation Class I (0-Degree, Flat-Top) Hexagon Quantization +// ============================================================================ +// +// This is the fundamental quantization algorithm. All other rotation classes +// use this as a building block via the surrogate-substrate pattern. + +/** + * Quantize a point to the nearest Rotation Class I (0-degree, flat-top) hexagon. + * + * Algorithm: + * 1. Convert Cartesian (x, y) to cube coordinates (q, r, s) + * 2. Round each cube coordinate to nearest integer + * 3. Fix rounding to maintain q + r + s = 0 constraint + * 4. Output offset coordinates (i, j) = (q, r) + * + * @param x X coordinate in hex grid space + * @param y Y coordinate in hex grid space + * @param out_i Output: column index (q from cube coords) + * @param out_j Output: row index (r from cube coords) + */ +inline void quantize_rotation_classI(double x, double y, + long long& out_i, long long& out_j) { + // Guard against NaN/Inf inputs to avoid UB in CubeCoord::round_to_nearest()'s + // std::llround() call. Matches coordinate_transforms.cpp::quantize_class1. + if (!std::isfinite(x) || !std::isfinite(y)) { + out_i = 0; + out_j = 0; + return; + } + + // Convert to cube coordinates using flat-top layout + CubeCoord cube = cartesian_to_cube(x, y, kSqrt3); + + // Round to nearest hex center (maintains q + r + s = 0) + cube.round_to_nearest(); + + // Extract offset coordinates + out_i = static_cast(cube.q); + out_j = static_cast(cube.r); +} + +/** + * Get the Cartesian center of a Rotation Class I (0-degree, flat-top) hexagon. + * + * @param i Column index + * @param j Row index + * @param out_x Output: X coordinate of cell center + * @param out_y Output: Y coordinate of cell center + */ +inline void center_rotation_classI(long long i, long long j, + double& out_x, double& out_y) { + cube_to_cartesian(static_cast(i), static_cast(j), + out_x, out_y, kSin60); +} + +// ============================================================================ +// Rotation Class II (30-Degree, Pointy-Top) Hexagon Quantization +// ============================================================================ +// +// Rotation Class II hexagons are rotated 30 degrees from Rotation Class I. +// Uses the surrogate-substrate pattern. + +/** + * Quantize a point to the nearest Rotation Class II (30-degree, pointy-top) hexagon. + * + * Uses surrogate-substrate pattern: + * 1. Rotate by -30 deg to align with Rotation Class I surrogate + * 2. Quantize in Rotation Class I grid + * 3. Get surrogate center, rotate back by +30 deg + * 4. Scale by sqrt(3) to substrate, re-quantize in Rotation Class I grid + * + * @param x X coordinate in hex grid space + * @param y Y coordinate in hex grid space + * @param out_i Output: column index in substrate coordinates + * @param out_j Output: row index in substrate coordinates + */ +inline void quantize_rotation_classII(double x, double y, + long long& out_i, long long& out_j) { + // Pre-computed rotation constants for -30 degrees + constexpr double cos_neg30 = kCos30; // cos(-30) = cos(30) + constexpr double sin_neg30 = -kSin30; // sin(-30) = -sin(30) = -0.5 + + // Step 1: Rotate to Rotation Class I surrogate frame (-30 degrees) + double sur_x = x * cos_neg30 - y * sin_neg30; + double sur_y = x * sin_neg30 + y * cos_neg30; + + // Step 2: Quantize in Rotation Class I surrogate + long long sur_i, sur_j; + quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); + + // Step 3: Get surrogate center + double cen_x, cen_y; + center_rotation_classI(sur_i, sur_j, cen_x, cen_y); + + // Step 4: Rotate center back to original frame (+30 degrees) + // Inverse rotation: cos same, sin negated + double back_x = cen_x * cos_neg30 + cen_y * sin_neg30; + double back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; + + // Step 5: Scale to substrate (sqrt(3) finer) and re-quantize + quantize_rotation_classI(back_x * kSqrt3, back_y * kSqrt3, out_i, out_j); +} + +// ============================================================================ +// Rotation Class III (Aperture-7) Hexagon Quantization +// ============================================================================ +// +// Aperture-7 uses hexagons rotated by arctan(sqrt(3/7)) = ~19.1 degrees. +// Two variants alternate by resolution: +// - Rotation Class III-A (~19 deg, even res): Rotation Class I + 19.1 deg +// - Rotation Class III-B (~49 deg, odd res): Rotation Class II + 19.1 deg + +// Pre-computed constants for ~19.1 degree rotation +namespace detail { + // arctan(sqrt(3)/5) in radians + constexpr double kAp7RotRad = kAp7RotDeg * kDegToRad; + constexpr double kCos19 = 0.9449111825230680440492389263705078; // cos(19.106...°) + constexpr double kSin19 = 0.3273268353539885718950317563490135; // sin(19.106...°) +} + +/** + * Quantize to Rotation Class III-A (~19-degree) hexagon (aperture-7, even resolutions). + * + * Surrogate: Rotation Class I grid rotated by -19.1 degrees + * Substrate: sqrt(7) times finer than surrogate + */ +inline void quantize_rotation_classIII_A(double x, double y, + long long& out_i, long long& out_j) { + using namespace detail; + + // Step 1: Rotate to Rotation Class I surrogate frame (-19.1 degrees) + double sur_x = x * kCos19 + y * kSin19; // cos(-a) = cos(a) + double sur_y = -x * kSin19 + y * kCos19; // sin(-a) = -sin(a) + + // Step 2: Quantize in Rotation Class I surrogate + long long sur_i, sur_j; + quantize_rotation_classI(sur_x, sur_y, sur_i, sur_j); + + // Step 3: Get surrogate center + double cen_x, cen_y; + center_rotation_classI(sur_i, sur_j, cen_x, cen_y); + + // Step 4: Rotate back to original frame (+19.1 degrees) + double back_x = cen_x * kCos19 - cen_y * kSin19; + double back_y = cen_x * kSin19 + cen_y * kCos19; + + // Step 5: Scale to substrate (sqrt(7) finer) and re-quantize + quantize_rotation_classI(back_x * kSqrt7, back_y * kSqrt7, out_i, out_j); +} + +/** + * Quantize to Rotation Class III-B (~49-degree) hexagon (aperture-7, odd resolutions). + * + * Surrogate: Rotation Class II grid rotated by -19.1 degrees + * (equivalent to Rotation Class I grid rotated by -49.1 degrees) + * Substrate: sqrt(21) times finer than surrogate + */ +inline void quantize_rotation_classIII_B(double x, double y, + long long& out_i, long long& out_j) { + using namespace detail; + + // Step 1: Rotate to surrogate frame (-19.1 degrees) + double sur_x = x * kCos19 + y * kSin19; + double sur_y = -x * kSin19 + y * kCos19; + + // Step 2: The surrogate is Rotation Class II (rotated 30 deg from Rotation Class I). + // To quantize, first rotate to Rotation Class I orientation. + constexpr double cos_neg30 = kCos30; + constexpr double sin_neg30 = -0.5; + + double rotated_x = sur_x * cos_neg30 - sur_y * sin_neg30; + double rotated_y = sur_x * sin_neg30 + sur_y * cos_neg30; + + // Step 3: Quantize in Rotation Class I grid + long long sur_i, sur_j; + quantize_rotation_classI(rotated_x, rotated_y, sur_i, sur_j); + + // Step 4: Get Rotation Class I center + double cen_x, cen_y; + center_rotation_classI(sur_i, sur_j, cen_x, cen_y); + + // Step 5: Rotate back to Rotation Class II orientation (+30 degrees) + double rotated_back_x = cen_x * cos_neg30 + cen_y * sin_neg30; + double rotated_back_y = -cen_x * sin_neg30 + cen_y * cos_neg30; + + // Step 6: Rotate back to original frame (+19.1 degrees) + double back_x = rotated_back_x * kCos19 - rotated_back_y * kSin19; + double back_y = rotated_back_x * kSin19 + rotated_back_y * kCos19; + + // Step 7: Scale to substrate (sqrt(21) finer) and re-quantize + quantize_rotation_classI(back_x * kSqrt21, back_y * kSqrt21, out_i, out_j); +} + +// ============================================================================ +// Hexagon Corner Generation +// ============================================================================ + +/** + * Generate the 6 corners of a hexagon given its center and radius. + * + * @param cx Center X coordinate + * @param cy Center Y coordinate + * @param radius Distance from center to each corner + * @param rotation_deg Rotation offset in degrees (0 = flat-top, 30 = pointy-top) + * @param out_x Output array of 6 X coordinates + * @param out_y Output array of 6 Y coordinates + */ +inline void generate_hex_corners(double cx, double cy, double radius, + double rotation_deg, + double* out_x, double* out_y) { + double rotation_rad = rotation_deg * kDegToRad; + + // Generate 6 vertices starting at top, counter-clockwise + for (int k = 0; k < 6; ++k) { + double angle = kPiOver2 + rotation_rad + k * kPiOver3; + out_x[k] = cx + radius * std::cos(angle); + out_y[k] = cy + radius * std::sin(angle); + } +} + +// ============================================================================ +// Aperture Scale Factors +// ============================================================================ + +/** + * Get the cumulative scale factor for a given aperture and resolution. + * + * @param aperture Aperture type (3, 4, or 7) + * @param resolution Grid resolution level + * @return Scale factor to multiply coordinates by + */ +inline double aperture_scale(int aperture, int resolution) { + switch (aperture) { + case 3: return std::pow(kSqrt3, resolution); + case 4: return std::pow(2.0, resolution); + case 7: return std::pow(kSqrt7, resolution); + default: return 1.0; + } +} + +/** + * Get the substrate scale multiplier for the current orientation class. + * + * @param aperture Aperture type + * @param resolution Grid resolution level + * @return Additional scale factor for substrate coordinates + */ +inline double substrate_multiplier(int aperture, int resolution) { + bool is_even = (resolution % 2 == 0); + + switch (aperture) { + case 3: + // Rotation Class I (even): no extra scale + // Rotation Class II (odd): sqrt(3) substrate + return is_even ? 1.0 : kSqrt3; + case 4: + // Always Rotation Class I, no extra scale + return 1.0; + case 7: + // Rotation Class III-A (even): sqrt(7) substrate + // Rotation Class III-B (odd): sqrt(21) substrate + return is_even ? kSqrt7 : kSqrt21; + default: + return 1.0; + } +} + +/** + * Get the rotation offset in degrees for hexagon corners. + * + * @param aperture Aperture type + * @param resolution Grid resolution level + * @return Rotation offset in degrees + */ +inline double corner_rotation_deg(int aperture, int resolution) { + bool is_even = (resolution % 2 == 0); + + switch (aperture) { + case 3: + // Rotation Class I: 0 deg, Rotation Class II: 30 deg + return is_even ? 0.0 : 30.0; + case 4: + // Always Rotation Class I + return 0.0; + case 7: + // Rotation Class III-A: ~19.1 deg, Rotation Class III-B: ~49.1 deg + return is_even ? kAp7RotDeg : (kAp7RotDeg + 30.0); + default: + return 0.0; + } +} + +} // namespace hexify + +#endif // HEXIFY_GRID_MATH_H