Skip to content

Commit f7177d6

Browse files
authored
Merge pull request #340 from jeffeb3/feature/flow-tiles
Flow tiles
2 parents ac0d49a + 532447e commit f7177d6

8 files changed

Lines changed: 1109 additions & 0 deletions

File tree

src/features/shapes/flow_tile/FlowTile.js

Lines changed: 612 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import FlowTile from "./FlowTile"
2+
import { tileRenderers } from "./tileRenderers"
3+
import { tileBounds, getEdgePoints, getEdgeMidpoints } from "./geometry"
4+
5+
describe("FlowTile", () => {
6+
let shape
7+
8+
beforeEach(() => {
9+
shape = new FlowTile()
10+
})
11+
12+
describe("drawOuterBorder", () => {
13+
it("creates segments for tile perimeter", () => {
14+
const segments = shape.drawOuterBorder(3, 3)
15+
16+
// Each edge has 2 segments per tile (split at midpoint)
17+
// 3 tiles per edge * 2 segments * 4 edges = 24 segments
18+
expect(segments.length).toBe(24)
19+
})
20+
21+
it("segments connect at tile midpoints", () => {
22+
const segments = shape.drawOuterBorder(2, 2)
23+
const topSegments = segments.slice(0, 4) // First 4 are top edge
24+
25+
// Check midpoint connections
26+
expect(topSegments[0][1].x).toBe(topSegments[1][0].x)
27+
expect(topSegments[0][1].y).toBe(topSegments[1][0].y)
28+
})
29+
})
30+
})
31+
32+
describe("tileRenderers", () => {
33+
const bounds = tileBounds(0, 0, 2)
34+
35+
describe("Arc", () => {
36+
it("produces 2 arc paths without stroke", () => {
37+
const paths = tileRenderers.Arc(bounds, 0, 0, false)
38+
39+
expect(paths.length).toBe(2)
40+
})
41+
42+
it("produces 4 paths with stroke (inner/outer for each arc)", () => {
43+
const paths = tileRenderers.Arc(bounds, 0, 0.25, false)
44+
45+
expect(paths.length).toBe(4)
46+
})
47+
48+
it("produces 6 paths with border (2 arcs + 4 border segments)", () => {
49+
const paths = tileRenderers.Arc(bounds, 0, 0, true)
50+
51+
expect(paths.length).toBe(6)
52+
})
53+
54+
it("produces different paths for different orientations", () => {
55+
const paths0 = tileRenderers.Arc(bounds, 0, 0, false)
56+
const paths1 = tileRenderers.Arc(bounds, 1, 0, false)
57+
58+
// Arc endpoints should differ between orientations
59+
const start0 = paths0[0][0]
60+
const start1 = paths1[0][0]
61+
62+
expect(start0.x === start1.x && start0.y === start1.y).toBe(false)
63+
})
64+
})
65+
66+
describe("Diagonal", () => {
67+
it("produces 2 line paths without stroke", () => {
68+
const paths = tileRenderers.Diagonal(bounds, 0, 0, false)
69+
70+
expect(paths.length).toBe(2)
71+
})
72+
73+
it("produces 4 paths with stroke", () => {
74+
const paths = tileRenderers.Diagonal(bounds, 0, 0.3, false)
75+
76+
expect(paths.length).toBe(4)
77+
})
78+
79+
it("produces 6 paths with border", () => {
80+
const paths = tileRenderers.Diagonal(bounds, 0, 0, true)
81+
82+
expect(paths.length).toBe(6)
83+
})
84+
85+
it("line paths have 2 points each", () => {
86+
const paths = tileRenderers.Diagonal(bounds, 0, 0, false)
87+
88+
expect(paths[0].length).toBe(2)
89+
expect(paths[1].length).toBe(2)
90+
})
91+
})
92+
})
93+
94+
describe("geometry", () => {
95+
describe("tileBounds", () => {
96+
it("computes bounds centered on given point", () => {
97+
const bounds = tileBounds(4, 6, 2)
98+
99+
expect(bounds.cx).toBe(4)
100+
expect(bounds.cy).toBe(6)
101+
expect(bounds.left).toBe(3)
102+
expect(bounds.right).toBe(5)
103+
expect(bounds.top).toBe(5)
104+
expect(bounds.bottom).toBe(7)
105+
})
106+
107+
it("defaults to size 2", () => {
108+
const bounds = tileBounds(0, 0)
109+
110+
expect(bounds.size).toBe(2)
111+
expect(bounds.right - bounds.left).toBe(2)
112+
})
113+
})
114+
115+
describe("getEdgePoints", () => {
116+
it("returns midpoints for fraction 0.5", () => {
117+
const bounds = tileBounds(0, 0, 2)
118+
const points = getEdgePoints(bounds, [0.5])
119+
120+
expect(points.left[0].x).toBe(-1)
121+
expect(points.left[0].y).toBe(0)
122+
expect(points.right[0].x).toBe(1)
123+
expect(points.right[0].y).toBe(0)
124+
expect(points.top[0].x).toBe(0)
125+
expect(points.top[0].y).toBe(-1)
126+
expect(points.bottom[0].x).toBe(0)
127+
expect(points.bottom[0].y).toBe(1)
128+
})
129+
130+
it("returns multiple points for multiple fractions", () => {
131+
const bounds = tileBounds(0, 0, 3)
132+
const points = getEdgePoints(bounds, [1 / 3, 2 / 3])
133+
134+
expect(points.left.length).toBe(2)
135+
expect(points.top.length).toBe(2)
136+
})
137+
})
138+
139+
describe("getEdgeMidpoints", () => {
140+
it("returns named midpoint properties", () => {
141+
const bounds = tileBounds(0, 0, 2)
142+
const { midLeft, midRight, midTop, midBottom } = getEdgeMidpoints(bounds)
143+
144+
expect(midLeft.x).toBe(-1)
145+
expect(midLeft.y).toBe(0)
146+
expect(midRight.x).toBe(1)
147+
expect(midRight.y).toBe(0)
148+
expect(midTop.x).toBe(0)
149+
expect(midTop.y).toBe(-1)
150+
expect(midBottom.x).toBe(0)
151+
expect(midBottom.y).toBe(1)
152+
})
153+
})
154+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Flow Tile (Truchet)
2+
3+
Generates Truchet tiling patterns - square tiles with two orientations that connect to form continuous flowing paths.
4+
5+
## Architecture
6+
7+
Tiles are drawn independently, then unified into a single continuous path:
8+
9+
1. **Draw tiles**: Each tile generates arc/line paths based on its orientation (determined by seed + noise).
10+
11+
2. **Draw outer border**: Perimeter segments connect tile edges to form a closed boundary.
12+
13+
3. **Build graph**: Path endpoints become nodes; paths become edges with stored vertex data.
14+
15+
4. **Connect components**: Disconnected subgraphs are bridged via MST.
16+
17+
5. **Eulerize**: Duplicate edges added for odd-degree vertices (Chinese Postman).
18+
19+
6. **Traverse**: Eulerian trail visits every edge exactly once, expanding stored paths.
20+
21+
This produces a single continuous drawing path.
22+
23+
## Tile Styles
24+
25+
| Style | Connection Points | Description |
26+
|-------|-------------------|-------------|
27+
| Arc | Edge midpoints (1/2) | Quarter-circle arcs at opposite corners. Classic Smith Truchet. |
28+
| Diagonal | Edge midpoints (1/2) | Straight lines connecting opposite edges. Maze-like patterns. |
29+
30+
## Potential Enhancements
31+
32+
**Tile Styles**
33+
- **Multiscale Truchet**: [Carlson's multi-scale patterns](https://christophercarlson.com/portfolio/multi-scale-truchet-patterns/) - recursive subdivision where any tile can be replaced by four smaller tiles.
34+
- **Maze tiles**: 4-gate connections (straight, corner, T, cross) for true maze generation.
35+
- **Weave tiles**: Over/under crossings with alternating pattern.
36+
37+
**Grid Variations** (would require new shape or significant refactor - tile geometry, borders, and stroke width are grid-specific; path connection logic is reusable)
38+
- **Hexagonal**: 3 arc variations per tile, weave-like patterns. See [SciPython](https://scipython.com/blog/hexagonal-truchet-tiling/).
39+
- **Triangle**: 2 points per edge, 3 tile types. See [Bridges 2020](https://www.archive.bridgesmathart.org/2020/bridges2020-191.pdf).
40+
41+
**Other**
42+
- **Orientation bias**: Slider to control probability of orientation 0 vs 1.
43+
44+
## References
45+
46+
- [Wikipedia: Truchet tiles](https://en.wikipedia.org/wiki/Truchet_tiles)
47+
- [Carlson: Multi-Scale Truchet Patterns](https://christophercarlson.com/portfolio/multi-scale-truchet-patterns/)
48+
- [Bridges 2018 Paper](https://archive.bridgesmathart.org/2018/bridges2018-39.html) - Carlson's original paper on multi-scale patterns
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Victor from "victor"
2+
3+
// Compute bounding box for a tile centered at (cx, cy)
4+
export function tileBounds(cx, cy, size = 2) {
5+
const half = size / 2
6+
7+
return {
8+
left: cx - half,
9+
right: cx + half,
10+
top: cy - half,
11+
bottom: cy + half,
12+
cx,
13+
cy,
14+
size,
15+
}
16+
}
17+
18+
// Get connection points along tile edges at specified fractions
19+
export function getEdgePoints(bounds, fractions = [0.5]) {
20+
const { left, right, top, bottom } = bounds
21+
const width = right - left
22+
const height = bottom - top
23+
24+
return {
25+
left: fractions.map((f) => new Victor(left, top + height * f)),
26+
right: fractions.map((f) => new Victor(right, top + height * f)),
27+
top: fractions.map((f) => new Victor(left + width * f, top)),
28+
bottom: fractions.map((f) => new Victor(left + width * f, bottom)),
29+
}
30+
}
31+
32+
// Get edge midpoints as named properties
33+
export function getEdgeMidpoints(bounds) {
34+
const edgePoints = getEdgePoints(bounds, [0.5])
35+
36+
return {
37+
midLeft: edgePoints.left[0],
38+
midRight: edgePoints.right[0],
39+
midTop: edgePoints.top[0],
40+
midBottom: edgePoints.bottom[0],
41+
}
42+
}

0 commit comments

Comments
 (0)