commit 567622351fb6f201c2eac9b0afe58e141b8c1ba4
parent 512058110b95d62b34293c8129efce2d9ab06571
Author: Michael Camilleri <[email protected]>
Date: Sun, 17 May 2026 22:34:36 +0900
Phase cross-reference hatching onto a grid-wide lattice
The passive cross-reference texture introduced in c0cbf61 drew its line family
relative to each cell's own rect, so every cell restarted the pattern from its
local origin. Adjacent cells in the same group were therefore out of phase by
up to a full line step, leaving a visible jag at every shared border instead of
one continuous hatch across the run. CrossRefLines now anchors the family to a
lattice shared by the whole grid. CellView forwards each cell's row, column and
the grid's inter-cell spacing; the shape derives the cell's global origin
(pitch = side + spacing, so the 1pt border gap is accounted for) and snaps
every run's start to that lattice via firstAligned(at:congruentTo:step:). Down
diagonals hold constant globalX − globalY, up diagonals globalX + globalY, and
the orthogonal slopes constant globalY / globalX, so neighbouring cells resolve
to the identical family and meet exactly at the seam — including the composed
crosshatch.
The phase is computed from the live rect at draw time, not precomputed, so it
tracks any change in cell size (rotation, Split View, resize, differing puzzle
dimensions) for free; every cell in a given pass shares one size and therefore
one lattice. gridRow/gridCol join the .equatable() fast path so cells still
refresh correctly.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 73 insertions(+), 14 deletions(-)
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -10,6 +10,12 @@ struct CellView: View, Equatable {
/// belongs to no cross-referenced clue. Each group in the puzzle is
/// assigned a distinct pattern so separate links read differently.
var crossRefPattern: CrossRefPattern? = nil
+ /// This cell's grid position and the grid's inter-cell spacing. Passed
+ /// through to the cross-reference texture so its line family is phased
+ /// to a grid-wide lattice and stays continuous across cell borders.
+ var gridRow: Int = 0
+ var gridCol: Int = 0
+ var gridSpacing: CGFloat = 1
var isRelatedToFocus: Bool = false
var remoteWordTint: Color? = nil
var authorTint: Color? = nil
@@ -24,6 +30,8 @@ struct CellView: View, Equatable {
&& lhs.isSelected == rhs.isSelected
&& lhs.isHighlighted == rhs.isHighlighted
&& lhs.crossRefPattern == rhs.crossRefPattern
+ && lhs.gridRow == rhs.gridRow
+ && lhs.gridCol == rhs.gridCol
&& lhs.isRelatedToFocus == rhs.isRelatedToFocus
&& lhs.remoteWordTint == rhs.remoteWordTint
&& lhs.authorTint == rhs.authorTint
@@ -38,7 +46,12 @@ struct CellView: View, Equatable {
// colour) but below the number, letter and corner
// markers so it never fights legibility.
if let crossRefPattern {
- CrossRefPatternView(pattern: crossRefPattern)
+ CrossRefPatternView(
+ pattern: crossRefPattern,
+ row: gridRow,
+ col: gridCol,
+ spacing: gridSpacing
+ )
}
if cell.special == .circled {
Circle()
@@ -144,28 +157,37 @@ enum CrossRefPattern: CaseIterable, Sendable {
/// are clipped to the cell bounds; the whole overlay is non-interactive.
private struct CrossRefPatternView: View {
let pattern: CrossRefPattern
+ /// Grid position and inter-cell spacing, forwarded to `CrossRefLines`
+ /// so the hatching is phased to a grid-wide lattice.
+ let row: Int
+ let col: Int
+ let spacing: CGFloat
/// Neutral ink that reads on white and over the (lightly tinted)
/// selection/author fills alike. Tunable while we evaluate the look.
private let ink = Color.black.opacity(0.25)
private let lineWidth: CGFloat = 1
+ private func lines(_ slope: CrossRefLines.Slope) -> CrossRefLines {
+ CrossRefLines(slope: slope, row: row, col: col, spacing: spacing)
+ }
+
var body: some View {
Group {
switch pattern {
case .diagonalDown:
- CrossRefLines(slope: .down).stroke(ink, lineWidth: lineWidth)
+ lines(.down).stroke(ink, lineWidth: lineWidth)
case .diagonalUp:
- CrossRefLines(slope: .up).stroke(ink, lineWidth: lineWidth)
+ lines(.up).stroke(ink, lineWidth: lineWidth)
case .crosshatch:
ZStack {
- CrossRefLines(slope: .down).stroke(ink, lineWidth: lineWidth)
- CrossRefLines(slope: .up).stroke(ink, lineWidth: lineWidth)
+ lines(.down).stroke(ink, lineWidth: lineWidth)
+ lines(.up).stroke(ink, lineWidth: lineWidth)
}
case .horizontal:
- CrossRefLines(slope: .horizontal).stroke(ink, lineWidth: lineWidth)
+ lines(.horizontal).stroke(ink, lineWidth: lineWidth)
case .vertical:
- CrossRefLines(slope: .vertical).stroke(ink, lineWidth: lineWidth)
+ lines(.vertical).stroke(ink, lineWidth: lineWidth)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -174,42 +196,63 @@ private struct CrossRefPatternView: View {
}
}
-/// Parallel lines across the cell at one of four orientations. Diagonal
-/// runs are generated past the edges and clipped by the caller so the
-/// hatching stays continuous to the cell border.
+/// Parallel lines across the cell at one of four orientations. The line
+/// family is anchored to a grid-wide lattice derived from the cell's
+/// position rather than its own origin, so adjacent cells draw the same
+/// family and the hatching meets exactly at every border. Runs are
+/// generated past the edges and clipped by the caller.
private struct CrossRefLines: Shape {
enum Slope { case down, up, horizontal, vertical }
var slope: Slope
+ /// This cell's grid position and the grid's inter-cell spacing. The
+ /// lattice phase is computed from these so every cell, at whatever
+ /// size the layout currently gives it, lands on the same global
+ /// family of lines.
+ var row: Int
+ var col: Int
+ var spacing: CGFloat
var spacingFraction: CGFloat = 0.24
func path(in rect: CGRect) -> Path {
var path = Path()
let side = min(rect.width, rect.height)
let step = max(2, side * spacingFraction)
+ // Cells are square and uniformly pitched, so one pitch value
+ // covers both axes. Computed from the live rect, so it tracks
+ // any change in cell size automatically.
+ let pitch = side + spacing
+ let gx = CGFloat(col) * pitch
+ let gy = CGFloat(row) * pitch
+
switch slope {
case .down:
- var x = rect.minX - rect.height
+ // Lines of constant (globalX − globalY); snap the generator
+ // so that quantity is a multiple of `step` grid-wide.
+ let lower = rect.minX - rect.height
+ var x = firstAligned(at: lower, congruentTo: rect.minX - (gx - gy), step: step)
while x <= rect.maxX {
path.move(to: CGPoint(x: x, y: rect.minY))
path.addLine(to: CGPoint(x: x + rect.height, y: rect.maxY))
x += step
}
case .up:
- var x = rect.minX - rect.height
+ // Lines of constant (globalX + globalY).
+ let lower = rect.minX - rect.height
+ var x = firstAligned(at: lower, congruentTo: lower - (gx + gy), step: step)
while x <= rect.maxX {
path.move(to: CGPoint(x: x, y: rect.maxY))
path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
x += step
}
case .horizontal:
- var y = rect.minY + step / 2
+ var y = firstAligned(at: rect.minY, congruentTo: rect.minY - gy, step: step)
while y < rect.maxY {
path.move(to: CGPoint(x: rect.minX, y: y))
path.addLine(to: CGPoint(x: rect.maxX, y: y))
y += step
}
case .vertical:
- var x = rect.minX + step / 2
+ var x = firstAligned(at: rect.minX, congruentTo: rect.minX - gx, step: step)
while x < rect.maxX {
path.move(to: CGPoint(x: x, y: rect.minY))
path.addLine(to: CGPoint(x: x, y: rect.maxY))
@@ -218,6 +261,19 @@ private struct CrossRefLines: Shape {
}
return path
}
+
+ /// Smallest value ≥ `lower` that is congruent to `target` modulo
+ /// `step`. Starting each run here puts every cell's lines on one
+ /// shared global lattice, so they line up across cell borders.
+ private func firstAligned(
+ at lower: CGFloat,
+ congruentTo target: CGFloat,
+ step: CGFloat
+ ) -> CGFloat {
+ let delta = (target - lower).truncatingRemainder(dividingBy: step)
+ let offset = delta >= 0 ? delta : delta + step
+ return lower + offset
+ }
}
/// Small dot pinned to the top-leading corner of the cell, sized as a
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -43,6 +43,9 @@ struct GridView: View {
crossRefPattern: cellGroups[pos].map {
patternPalette[$0 % patternPalette.count]
},
+ gridRow: r,
+ gridCol: c,
+ gridSpacing: spacing,
isRelatedToFocus: relatedCells.contains(pos),
remoteWordTint: tintByCell[pos],
authorTint: square.entry.isEmpty