├── _config.yml ├── images ├── example0.png ├── example1.png ├── example2.png ├── example3.png ├── example4.png ├── example5.png └── screenshot.png ├── .gitignore ├── js ├── bootstrap.js ├── tiling_wasm.js ├── vector.js ├── statecode.js ├── controls.js ├── rendergl.js ├── cutproject.js ├── tilingview.js └── tiling.js ├── LICENSE ├── crate ├── Cargo.toml └── src │ ├── lib.rs │ └── multigrid.rs ├── README.md ├── docs ├── gallery.md └── intro.md ├── index.html └── css └── main.css /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /images/example0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/example0.png -------------------------------------------------------------------------------- /images/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/example1.png -------------------------------------------------------------------------------- /images/example2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/example2.png -------------------------------------------------------------------------------- /images/example3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/example3.png -------------------------------------------------------------------------------- /images/example4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/example4.png -------------------------------------------------------------------------------- /images/example5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/example5.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gglouser/cut-and-project-tiling/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .hg 2 | .hgignore 3 | tmp 4 | notes.txt 5 | node_modules 6 | dist 7 | crate/bin 8 | crate/pkg 9 | crate/target 10 | crate/Cargo.lock 11 | pkg/ 12 | -------------------------------------------------------------------------------- /js/bootstrap.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import initWasm from '../pkg/tiling_rs.js'; 5 | initWasm() 6 | .then(() => import("./cutproject.js")) 7 | .catch(e => console.error("Error importing `cutproject.js`:", e)); 8 | -------------------------------------------------------------------------------- /js/tiling_wasm.js: -------------------------------------------------------------------------------- 1 | // Extra glue for Rust WebAssembly implementation of multigrid tiling generator. 2 | import * as Tiling_rs from '../pkg/tiling_rs.js'; 3 | 4 | export function generateWasm(state, viewWidth, viewHeight) { 5 | const faceList = Tiling_rs.generate( 6 | state.dims, 7 | state.basis[0], 8 | state.basis[1], 9 | state.offset, 10 | viewWidth, 11 | viewHeight); 12 | 13 | const faces = []; 14 | const numFaces = faceList.get_num_faces(); 15 | for (let i = 0; i < numFaces; i++) { 16 | const face = faceList.get_face(i); 17 | faces.push({ 18 | keyVert: [face.key_vert_x, face.key_vert_y], 19 | axis1: face.axis1, 20 | axis2: face.axis2, 21 | }); 22 | face.free(); 23 | } 24 | faceList.free(); 25 | return faces; 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Grant Glouser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crate/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tiling-rs" 3 | version = "0.1.0" 4 | description = "Multigrid tiling generator" 5 | license = "MIT" 6 | authors = ["Grant Glouser "] 7 | repository = "https://github.com/gglouser/cut-and-project-tiling" 8 | edition = "2018" 9 | 10 | [lib] 11 | crate-type = ["cdylib"] 12 | 13 | [features] 14 | # default = ["console_error_panic_hook"] 15 | 16 | [dependencies] 17 | cfg-if = "0.1.10" 18 | wasm-bindgen = "0.2.80" 19 | nalgebra = "0.19.0" 20 | 21 | # The `console_error_panic_hook` crate provides better debugging of panics by 22 | # logging them with `console.error`. This is great for development, but requires 23 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 24 | # code size when deploying. 25 | console_error_panic_hook = { version = "0.1.6", optional = true } 26 | 27 | # `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size 28 | # compared to the default allocator's ~10K. It is slower than the default 29 | # allocator, however. 30 | # 31 | # Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now. 32 | wee_alloc = { version = "0.4.5", optional = true } 33 | 34 | [profile.release] 35 | # Tell `rustc` to optimize for small code size. 36 | opt-level = "s" 37 | 38 | # Link time optimization 39 | lto = true 40 | -------------------------------------------------------------------------------- /crate/src/lib.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | use wasm_bindgen::prelude::*; 3 | 4 | cfg_if! { 5 | // When the `console_error_panic_hook` feature is enabled, we can call the 6 | // `set_panic_hook` function to get better error messages if we ever panic. 7 | if #[cfg(feature = "console_error_panic_hook")] { 8 | extern crate console_error_panic_hook; 9 | use console_error_panic_hook::set_once as set_panic_hook; 10 | } else { 11 | #[inline] 12 | fn set_panic_hook() {} 13 | } 14 | } 15 | 16 | cfg_if! { 17 | // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global 18 | // allocator. 19 | if #[cfg(feature = "wee_alloc")] { 20 | extern crate wee_alloc; 21 | #[global_allocator] 22 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 23 | } 24 | } 25 | 26 | pub mod multigrid; 27 | 28 | #[wasm_bindgen] 29 | #[derive(Clone, Debug)] 30 | pub struct Face { 31 | pub key_vert_x: f64, 32 | pub key_vert_y: f64, 33 | pub axis1: u16, 34 | pub axis2: u16, 35 | } 36 | 37 | #[wasm_bindgen] 38 | pub struct FaceList { 39 | faces: Vec 40 | } 41 | 42 | #[wasm_bindgen] 43 | impl FaceList { 44 | pub fn get_num_faces(&self) -> usize { 45 | self.faces.len() 46 | } 47 | 48 | pub fn get_face(&self, i: usize) -> Face { 49 | self.faces[i].clone() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cut-and-Project Tiling 2 | 3 | This applet is an interactive demonstration of cut-and-project tiling. 4 | 5 | ![sample screenshot](images/screenshot.png) 6 | 7 | Cut-and-project tilings are made by a 2-dimensional plane cutting through a higher dimensional square lattice. Lattice points in the neighborhood of the cutting plane are projected onto the plane and connected by edges to create the tiling. The well-known Penrose tiling is among the many tilings that can be generated this way. 8 | 9 | [Try it!](https://gglouser.github.io/cut-and-project-tiling/) 10 | 11 | [Explanation of controls](docs/intro.md) and how it works. 12 | 13 | Check out a small [gallery of examples](docs/gallery.md). 14 | 15 | ## Requirements 16 | 17 | This applet includes a WebAssembly implementation of the tiling generator. That piece is written in Rust and uses wasm-bindgen to build the wasm module. 18 | 19 | Prerequisites: 20 | 21 | 1. Install [Rust and cargo](https://www.rust-lang.org/tools/install) 22 | 23 | 2. Install [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) 24 | 25 | To build the wasm module: 26 | 27 | cd crate 28 | cargo build --target wasm32-unknown-unknown --release 29 | wasm-bindgen --target web --out-dir ../pkg ./target/wasm32-unknown-unknown/release/tiling_rs.wasm 30 | 31 | To build the `dist` directory for deployment: 32 | 33 | py build_dist.py --clean 34 | 35 | 36 | ## Acknowledgments 37 | 38 | I drew inspiration from [Quasitiler](http://www.geom.uiuc.edu/apps/quasitiler/about.html). My favorite feature borrowed from it is the axis control rosette. Before that, I was trying to control the orientation of the cutting plane with angles, and Quasitiler's way is far superior. 39 | -------------------------------------------------------------------------------- /docs/gallery.md: -------------------------------------------------------------------------------- 1 | # Example Tilings 2 | 3 | Classic Penrose tiling of thick and thin rhombs. [See this example live in the applet.](https://gglouser.github.io/cut-and-project-tiling/?a=AQVQ9BkEvoK+ghkEAABM/S+V0GuzAzw8PDw8AAAA9vb2MLAwJIwk////7OzsLKQsIIAg4uLiKJgo2NjY) 4 | 5 | ![Penrose tiling](../images/example0.png) 6 | 7 | A faux-Penrose tiling. It uses the same cutting plane orientation, and therefore is made of the same thick and thin rhombs, but at a different offset so that it doesn't satisfy the edge-matching rules of a true Penrose tiling. (The thick rhombs are all colored black for visual effect.) [See this example live.](https://gglouser.github.io/cut-and-project-tiling/?a=AQVQ9BkEvoK+ghkEAABM/S+V0GuzAygoKCgoAAAAAAAAAP//AMD/AAAAAAAAAID/AIDAAAAAAMDAAAAA) 8 | 9 | ![icy](../images/example1.png) 10 | 11 | A 5-axis tiling with a different cutting plane orientation. Has a vaguely crystalline appearance. [See this example live.](https://gglouser.github.io/cut-and-project-tiling/?a=AQV61RMk8VvoiQzQAE9Aox4q6quX4jw8lDw8AAAAn5//amr/lZX/gID/AGpqAIAAAIAAAGpqAICAAIAA) 12 | 13 | ![crytal like](../images/example2.png) 14 | 15 | A 5-axis tiling that looks like ordinary (3-D) cubes with cracks running between them in two directions. [See this example live.](https://gglouser.github.io/cut-and-project-tiling/?a=AQVs1vEt4i3HYw7v7eBrDPMiwbfoiTw8PDw8AAAAAABAAID/QABA/wAAAID/AACA/wAAAID//4D//wAA) 16 | 17 | ![red blue world](../images/example3.png) 18 | 19 | A rainbow 6-axis tiling. [See this example live.](https://gglouser.github.io/cut-and-project-tiling/?a=AQZJ5kAAJPMAANsNwAEAACTzP/9J5kAAJPMAAAAAAAAAAAAA//8AgP8AAACA/wAA/wAAAP+AAP8AAAAA/4D/AP//AIAAAAD/AAD/gAD//wA=) 20 | 21 | ![color wheel](../images/example4.png) 22 | 23 | A flowery 7-axis tiling. [See this example live.](https://gglouser.github.io/cut-and-project-tiling/?a=AQdEayqo8MfCXMJc8McqqAAANX5CtB2v4lG9TMqCAAAAAAAAAAAAAICA/4AA/wCAAACAAEAAgICAwICA/4AA/wCAAACAAEAAgICAwEAAgACAAACAAICA/0AAgACAAICAwIAA/4CA/w==) 24 | 25 | ![flowery](../images/example5.png) 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cut and Project Tiling 5 | 6 | 7 | 8 | 9 | 10 | This applet requires the canvas, which your browser does not support. 11 |
12 |
About
13 |
14 | Cut and Project Tiling
15 | by Grant Glouser
16 | 21 |
22 |
23 | 24 | Tiling method: 25 | 30 |
31 |
Axis Controls
32 |
33 | 34 |
35 | 36 | axes 37 | 38 |
39 |
40 |
Offsets
41 |
42 |
Colors
43 |
44 |
45 |
46 |
Copy the tiling code below or use this link.
47 |
xyz
48 |
49 | 50 |
51 |
52 |
53 | Enter a tiling code: 54 | 55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | #main { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | height: 100%; 6 | width: 100%; 7 | touch-action: none; 8 | } 9 | #controls { 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | background-color: white; 14 | width: 180px; 15 | max-height: 100%; 16 | overflow: auto; 17 | border: solid; 18 | cursor: default; 19 | } 20 | #axisRosette { 21 | display: block; 22 | height: 180px; 23 | width: 180px; 24 | touch-action: none; 25 | } 26 | #offsetControls>div { 27 | width: 100%; 28 | display: flex; 29 | align-items: center; 30 | justify-content: space-between; 31 | } 32 | #offsetControls input { 33 | flex-grow: 1; 34 | } 35 | .offset-label { 36 | font-size: 10pt; 37 | } 38 | #colorControls input { 39 | border: 0; 40 | padding: 0; 41 | } 42 | #axesExtra { 43 | display: flex; 44 | } 45 | #numAxes { 46 | width: 20%; 47 | } 48 | #numAxesLabel { 49 | padding: 0 5px 0 2px; 50 | } 51 | #reset { 52 | flex-grow: 1; 53 | } 54 | .toggle { 55 | font-family: sans-serif; 56 | font-variant: small-caps; 57 | font-weight: bold; 58 | background-color: #cccccc; 59 | padding: 3px; 60 | } 61 | .toggle:hover, .active { 62 | background-color: #ccddff; 63 | } 64 | .toggle:after { 65 | content: '\FF0B'; /* Unicode fullwidth plus sign */ 66 | float: right; 67 | } 68 | .active:after { 69 | content: '\FF0D'; /* Unicode fullwidth hyphen-minus */ 70 | } 71 | #about i { 72 | font-size: smaller; 73 | } 74 | #about ul { 75 | margin: 0px; 76 | padding-inline-start: 20px; 77 | } 78 | #about input { 79 | width: 100%; 80 | } 81 | #codeShow { 82 | position: absolute; 83 | top: -200px; 84 | left: 200px; 85 | padding: 5px; 86 | background-color: #eeeeee; 87 | border: solid; 88 | transition: 0.3s all; 89 | } 90 | #codeShow.codeActive, #codeLoad.codeActive { 91 | top: 10px; 92 | } 93 | #codeCode { 94 | display: inline-block; 95 | max-width: 400px; 96 | word-break: break-all; 97 | font-family: sans-serif; 98 | padding: 3px; 99 | background-color: white; 100 | border: 1px solid lightgrey; 101 | } 102 | #codeLoad { 103 | position: absolute; 104 | top: -100px; 105 | left: 200px; 106 | padding: 5px; 107 | background-color: #eeeeee; 108 | border: solid; 109 | transition: 0.3s all; 110 | } 111 | -------------------------------------------------------------------------------- /js/vector.js: -------------------------------------------------------------------------------- 1 | // Functions to perform vector operations on arrays. 2 | 3 | export function zero(dim) { 4 | const v = new Array(dim); 5 | v.fill(0); 6 | return v; 7 | } 8 | 9 | export function elementary(dim, i) { 10 | const v = zero(dim); 11 | v[i] = 1; 12 | return v; 13 | } 14 | 15 | export function copy(v) { 16 | return v.slice(); 17 | } 18 | 19 | export function scale(v, a) { 20 | for (let i = 0; i < v.length; i++) { 21 | v[i] *= a; 22 | } 23 | } 24 | 25 | export function dot(v1, v2) { 26 | if (v1.length !== v2.length) { 27 | console.error("Vec.dot: mismatched vector lengths"); 28 | return undefined; 29 | } 30 | let d = 0; 31 | for (let i = 0; i < v1.length; i++) { 32 | d += v1[i] * v2[i]; 33 | } 34 | return d; 35 | } 36 | 37 | export function norm(v) { 38 | return Math.sqrt(dot(v, v)); 39 | } 40 | 41 | export function normalize(v) { 42 | const d = dot(v,v); 43 | if (d > Number.EPSILON) { 44 | scale(v, 1/Math.sqrt(d)); 45 | } else { 46 | v.fill(0); 47 | } 48 | } 49 | 50 | // Change v to have a norm of 1, BUT with the extra constraint 51 | // that v[axis] = k (0 <= k <= 1). 52 | export function renormalize(v, axis, k) { 53 | const norm2 = 1 - k**2; 54 | const v_norm2 = v.reduce((a,x,i) => (i === axis ? a : a + x**2), 0); 55 | if (v_norm2 > Number.EPSILON) { 56 | const f = Math.sqrt(norm2 / v_norm2); 57 | scale(v, f); 58 | } else { 59 | const x = Math.sqrt(norm2 / (v.length - 1)); 60 | v.fill(-x); 61 | } 62 | v[axis] = k; 63 | } 64 | 65 | export function add(v1, v2) { 66 | if (v1.length !== v2.length) { 67 | console.error("Vec.add: mismatched vector lengths"); 68 | return undefined; 69 | } 70 | const w = copy(v1); 71 | for (let i = 0; i < v1.length; i++) { 72 | w[i] += v2[i]; 73 | } 74 | return w; 75 | } 76 | 77 | export function sub(v1, v2) { 78 | if (v1.length !== v2.length) { 79 | console.error("Vec.sub: mismatched vector lengths"); 80 | return undefined; 81 | } 82 | const w = copy(v1); 83 | for (let i = 0; i < v1.length; i++) { 84 | w[i] -= v2[i]; 85 | } 86 | return w; 87 | } 88 | 89 | // Compute k1 v1 + k2 v2, where k1/k2 are scalars and v1/v2 are vectors. 90 | export function combine(k1, v1, k2, v2) { 91 | const w1 = copy(v1); 92 | scale(w1, k1); 93 | const w2 = copy(v2); 94 | scale(w2, k2); 95 | return add(w1, w2); 96 | } 97 | 98 | export function project(v, bases) { 99 | const w = zero(bases.length); 100 | for (let i = 0; i < bases.length; i++) { 101 | w[i] = dot(v, bases[i]); 102 | } 103 | return w; 104 | } 105 | 106 | export function makeOrtho(v, w) { 107 | const corr = copy(w); 108 | scale(corr, dot(v, w)); 109 | return sub(v, corr); 110 | } 111 | 112 | // Rotate the vector v in the plane defined by axes i and j 113 | // counterclockwise by an angle theta. 114 | export function rotate(v, i, j, theta) { 115 | const c = Math.cos(theta); 116 | const s = Math.sin(theta); 117 | const x = v[i]; 118 | const y = v[j]; 119 | v[i] = c*x - s*y; 120 | v[j] = s*x + c*y; 121 | } 122 | -------------------------------------------------------------------------------- /js/statecode.js: -------------------------------------------------------------------------------- 1 | export function base64ToBlob(base64str) { 2 | const bin = atob(base64str); 3 | const array = new Uint8Array(bin.length); 4 | for (let i = 0; i < bin.length; i++) { 5 | array[i] = bin.charCodeAt(i); 6 | } 7 | return new Blob([array]); 8 | } 9 | 10 | function colorhex(n) { 11 | return Math.max(0, Math.min(255, Math.round(n))) 12 | .toString(16).padStart(2, '0'); 13 | } 14 | 15 | export function makeColor([r,g,b]) { 16 | return `#${colorhex(r)}${colorhex(g)}${colorhex(b)}`; 17 | } 18 | 19 | function readColor(view, ptr) { 20 | const r = view.getUint8(ptr); 21 | const g = view.getUint8(ptr+1); 22 | const b = view.getUint8(ptr+2); 23 | return [r,g,b]; 24 | } 25 | 26 | export function splitColor(c) { 27 | const m = c.match(/#([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})/i); 28 | if (m !== null) { 29 | const r = Number.parseInt(m[1], 16); 30 | const g = Number.parseInt(m[2], 16); 31 | const b = Number.parseInt(m[3], 16); 32 | return [r,g,b]; 33 | } 34 | return [0,0,0]; 35 | } 36 | 37 | export function encodeState(state) { 38 | const vecSize = state.dims * 2; 39 | const offsetSize = state.dims; 40 | const colors = state.colors; 41 | const colorsSize = (1 + colors.length) * 3; 42 | const bufSize = 2 + 2*vecSize + offsetSize + colorsSize; 43 | 44 | const buffer = new ArrayBuffer(bufSize); 45 | const view = new DataView(buffer); 46 | view.setUint8(0, 1); // format version code -- VERSION 1 47 | view.setUint8(1, state.dims); 48 | 49 | // Write basis vectors. 50 | let ptr = 2; 51 | state.basis.forEach((v) => v.forEach((x) => { 52 | view.setInt16(ptr, Math.round(x * 32767)); 53 | ptr += 2; 54 | })); 55 | 56 | // Write offset. 57 | state.offset.forEach((o) => { 58 | view.setUint8(ptr, Math.round(o * 200)); 59 | ptr += 1; 60 | }); 61 | 62 | // Write line color. 63 | state.lineColor.forEach((v,i) => view.setUint8(ptr + i, v)); 64 | ptr += 3; 65 | 66 | // Write face colors. 67 | colors.forEach((c) => { 68 | c.forEach((v, i) => view.setUint8(ptr + i, v)); 69 | ptr += 3; 70 | }); 71 | 72 | return new Blob([buffer]); 73 | } 74 | 75 | export function decodeState(buffer) { 76 | try { 77 | const view = new DataView(buffer); 78 | const version = view.getUint8(0); 79 | if (version !== 1) { 80 | console.error('Tiling state decode: invalid format'); 81 | return null; 82 | } 83 | const dims = view.getUint8(1); 84 | if (dims < 3 || dims > 7) { 85 | console.error('Tiling state decode: unsupported dimension'); 86 | return null; 87 | } 88 | 89 | // Read basis vectors. 90 | let ptr = 2; 91 | const basis = []; 92 | for (let i = 0; i < 2; i++) { 93 | const v = []; 94 | for (let j = 0; j < dims; j++) { 95 | v.push(view.getInt16(ptr) / 32767); 96 | ptr += 2; 97 | } 98 | basis.push(v); 99 | } 100 | 101 | // Read offset. 102 | const offset = []; 103 | for (let i = 0; i < dims; i++) { 104 | offset.push(view.getUint8(ptr) / 200); 105 | ptr += 1; 106 | } 107 | 108 | // Read line color. 109 | const lineColor = readColor(view, ptr); 110 | ptr += 3; 111 | 112 | // Read colors. 113 | const colors = []; 114 | for (let i = 0; i < dims*(dims-1)/2; i++) { 115 | colors.push(readColor(view, ptr)); 116 | ptr += 3; 117 | } 118 | 119 | return { 120 | dims, 121 | basis, 122 | offset, 123 | lineColor, 124 | colors, 125 | }; 126 | 127 | } catch (error) { 128 | if (error instanceof RangeError) { 129 | console.error('Tiling state decode: invalid state code (RangeError)'); 130 | return null; 131 | } else { 132 | throw error; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /js/controls.js: -------------------------------------------------------------------------------- 1 | const AXIS_CONTROL_SCALE = 60; 2 | const POINT_SIZE_PX = 4; 3 | const POINT_SIZE = POINT_SIZE_PX/AXIS_CONTROL_SCALE; 4 | const POINT_DIST = ((POINT_SIZE_PX + 4)/AXIS_CONTROL_SCALE)**2; 5 | 6 | class ControlPoint { 7 | constructor(x, y) { 8 | this.x = x; 9 | this.y = y; 10 | } 11 | 12 | draw(ctx) { 13 | ctx.beginPath(); 14 | ctx.arc(this.x, this.y, POINT_SIZE, 0, 2*Math.PI); 15 | ctx.fill(); 16 | } 17 | 18 | contains(x, y) { 19 | const dx = x - this.x; 20 | const dy = y - this.y; 21 | return dx*dx + dy*dy < POINT_DIST; 22 | } 23 | 24 | valueChanged() {} 25 | 26 | mouseAction(x, y) { 27 | this.x = x; 28 | this.y = y; 29 | this.valueChanged(); 30 | } 31 | } 32 | 33 | export class AxisControls { 34 | constructor(canvas, app, dims) { 35 | this.canvas = canvas; 36 | this.app = app; 37 | this.tracking = null; 38 | canvas.width = canvas.clientWidth; 39 | canvas.height = canvas.clientHeight; 40 | canvas.addEventListener('mousedown', this, false); 41 | canvas.addEventListener('mousemove', this, false); 42 | canvas.addEventListener('mouseup', this, false); 43 | canvas.addEventListener('touchstart', this, false); 44 | canvas.addEventListener('touchmove', this, false); 45 | canvas.addEventListener('touchend', this, false); 46 | this.setNumAxes(dims); 47 | } 48 | 49 | setNumAxes(dims) { 50 | this.ctls = []; 51 | for (let i = 0; i < dims; i++) { 52 | const ctl = new ControlPoint(0, 0); 53 | ctl.valueChanged = () => { 54 | this.app.axisChanged(i, ctl.x, ctl.y); 55 | }; 56 | this.ctls.push(ctl); 57 | } 58 | } 59 | 60 | draw() { 61 | const ctx = this.canvas.getContext('2d'); 62 | ctx.save(); 63 | ctx.fillStyle = 'white'; 64 | ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 65 | 66 | ctx.translate(this.canvas.width/2, this.canvas.height/2); 67 | ctx.scale(AXIS_CONTROL_SCALE, AXIS_CONTROL_SCALE); 68 | 69 | ctx.lineWidth = 1/AXIS_CONTROL_SCALE; 70 | ctx.strokeStyle = 'lightgrey'; 71 | ctx.beginPath(); 72 | ctx.arc(0, 0, Math.sqrt(2/this.ctls.length), 0, 2*Math.PI); 73 | ctx.stroke(); 74 | ctx.strokeStyle = 'grey'; 75 | ctx.beginPath(); 76 | ctx.arc(0, 0, 1, 0, 2*Math.PI); 77 | ctx.stroke(); 78 | 79 | ctx.strokeStyle = 'black'; 80 | ctx.beginPath(); 81 | for (let i = 0; i < this.ctls.length; i++) { 82 | ctx.moveTo(0, 0); 83 | ctx.lineTo(this.ctls[i].x, this.ctls[i].y); 84 | const j = (i + 1) % this.ctls.length; 85 | ctx.lineTo(this.ctls[i].x + this.ctls[j].x, this.ctls[i].y + this.ctls[j].y); 86 | ctx.lineTo(this.ctls[j].x, this.ctls[j].y); 87 | } 88 | ctx.stroke(); 89 | 90 | ctx.fillStyle = 'black'; 91 | this.ctls.forEach((e) => { e.draw(ctx); }); 92 | 93 | ctx.restore(); 94 | } 95 | 96 | handleEvent(event) { 97 | switch (event.type) { 98 | case 'mousedown': 99 | this.mouseDown(event); 100 | break; 101 | case 'mousemove': 102 | this.mouseMove(event); 103 | break; 104 | case 'mouseup': 105 | this.tracking = null; 106 | break; 107 | case 'touchstart': 108 | this.touchStart(event); 109 | break; 110 | case 'touchmove': 111 | this.touchMove(event); 112 | break; 113 | case 'touchend': 114 | this.tracking = null; 115 | break; 116 | } 117 | } 118 | 119 | toLocalPos(x, y) { 120 | return { 121 | x: (x - this.canvas.width/2)/AXIS_CONTROL_SCALE, 122 | y: (y - this.canvas.height/2)/AXIS_CONTROL_SCALE, 123 | }; 124 | } 125 | 126 | findControl(x, y) { 127 | const localPos = this.toLocalPos(x, y); 128 | return this.ctls.find((ctl) => ctl.contains(localPos.x, localPos.y)); 129 | } 130 | 131 | mouseAction(target, x, y) { 132 | const localPos = this.toLocalPos(x, y); 133 | target.mouseAction(localPos.x, localPos.y); 134 | } 135 | 136 | mouseDown(event) { 137 | this.tracking = this.findControl(event.offsetX, event.offsetY); 138 | } 139 | 140 | mouseMove(event) { 141 | if (this.tracking) { 142 | this.mouseAction(this.tracking, event.offsetX, event.offsetY); 143 | } 144 | } 145 | 146 | touchStart(event) { 147 | // Single touch only 148 | if (!this.tracking) { 149 | const touch = event.changedTouches[0]; 150 | const pos = touchPos(touch); 151 | const target = this.findControl(pos.x, pos.y); 152 | if (target) { 153 | event.preventDefault(); 154 | this.tracking = { 155 | target, 156 | identifier: touch.identifier, 157 | }; 158 | } 159 | } 160 | } 161 | 162 | touchMove(event) { 163 | if (this.tracking) { 164 | event.preventDefault(); 165 | const touch = findTouchByID(event.changedTouches, this.tracking.identifier); 166 | if (touch) { 167 | const pos = touchPos(touch); 168 | this.mouseAction(this.tracking.target, pos.x, pos.y); 169 | } 170 | } 171 | } 172 | } 173 | 174 | function findTouchByID(touches, id) { 175 | for (let i = 0; i < touches.length; i++) { 176 | if (touches[i].identifier === id) { 177 | return touches[i]; 178 | } 179 | } 180 | return null; 181 | } 182 | 183 | function touchPos(touch) { 184 | const rect = touch.target.getBoundingClientRect(); 185 | return { 186 | x: touch.clientX - rect.left, 187 | y: touch.clientY - rect.top, 188 | }; 189 | } 190 | 191 | export class OffsetControls { 192 | constructor(offsetControlsDiv, app, dims) { 193 | this.div = offsetControlsDiv; 194 | this.app = app; 195 | this.ctls = []; 196 | this.lbls = []; 197 | this.setNumAxes(dims); 198 | } 199 | 200 | addOffset() { 201 | const div = document.createElement('div'); 202 | this.div.append(div); 203 | 204 | const offsetIndex = this.ctls.length; 205 | const ctl = document.createElement('input'); 206 | ctl.type = 'range'; 207 | ctl.min = 0; 208 | ctl.max = 1; 209 | ctl.step = 0.01; 210 | this.ctls.push(ctl); 211 | div.append(ctl); 212 | 213 | const lbl = document.createElement('span'); 214 | lbl.className = 'offset-label'; 215 | this.lbls.push(lbl); 216 | div.append(lbl); 217 | 218 | ctl.addEventListener('input', (event) => { 219 | lbl.innerHTML = ctl.valueAsNumber.toFixed(2); 220 | this.app.offsetChanged(offsetIndex, ctl.valueAsNumber); 221 | }); 222 | } 223 | 224 | removeOffset() { 225 | this.div.removeChild(this.div.lastChild); 226 | this.ctls.pop(); 227 | this.lbls.pop(); 228 | } 229 | 230 | setNumAxes(dims) { 231 | while (this.ctls.length > dims) { 232 | this.removeOffset(); 233 | } 234 | while (this.ctls.length < dims) { 235 | this.addOffset(); 236 | } 237 | } 238 | 239 | setOffset(i, offset) { 240 | this.ctls[i].valueAsNumber = offset; 241 | this.lbls[i].innerHTML = offset.toFixed(2); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Cut and Project Tiling 2 | 3 | This applet draws plane tilings by *cutting* through a 3-or-more-dimensional square lattice with a 2-dimensional plane and *projecting* that slice of the lattice onto the cutting plane. This method, also called the projection method, was discovered by [de Bruijn](https://en.wikipedia.org/wiki/Nicolaas_Govert_de_Bruijn) in his investigation of the [Penrose tiling](https://en.wikipedia.org/wiki/Penrose_tiling) discovered by [Roger Penrose](https://en.wikipedia.org/wiki/Roger_Penrose). 4 | 5 | De Bruijn observed that the Penrose tiling appears as the projection of a 5D square lattice when the cutting plane has a particular orientation and offset to the lattice. He also showed how this is equivalent to a method that uses 5 sets of parallel lines, which he called a *pentagrid*. 6 | 7 | This tiling generator produces the tiling for any cutting plane orientation and offset, using either cut-and-project or a *multigrid* (generalized pentagrid). 8 | 9 | - [Quick description of the controls](#controls) 10 | - [Brief explanation of how it works](#how-it-works) 11 | - [Links to more information](#references) 12 | 13 | ## Controls 14 | 15 | Click and drag the tiling to pan around. Mouse-wheel to zoom. 16 | 17 | **Axis Controls:** change the orientation of the cutting plane. Click and drag the control points. Each control point corresponds to one axis of the lattice. You can also choose the number of dimensions (number of axes) of the lattice. 18 | 19 | **Offsets:** change the position of the cutting plane relative to the lattice. Specifically, this is the offset of the center of the tiling view relative to a nearby lattice point. Note that when you move the view by dragging, this will also change the offset! 20 | 21 | **Colors:** pick colors for the tiling faces. All faces of the same type get the same color, and there is one type for each face orientation in the lattice. A face orientation is determined by two axes, so for an n-dimensional lattice, there are n choose 2 = n(n-1)/2 face types. 22 | 23 | ## How It Works 24 | 25 | I have implemented both the cut-and-project and multigrid methods, and you can switch between them to see that they (almost) always produce exactly the same tiling. 26 | 27 | (They might not be the same when there is some point where three or more multigrid lines intersect, what de Bruijn called a "singular" multigrid. A singular multigrid is ambiguous and could produce more than one valid tiling, or an invalid tiling depending on the vagaries of floating point precision. I have a consistent way of handling this for the multigrid method, but not completely for the cut-and-project method.) 28 | 29 | #### Cut and project 30 | 31 | Points, edges, and faces in the tiling are points, edges, and faces of the lattice that are in the neighborhood of the cutting plane. What is this "neighborhood"? Imagine a unit hypercube (axis-aligned with the lattice) centered on a lattice point. If the cutting plane passes through that hypercube, then the point is in the neighborhood of the cutting plane ("in the cut"). If both ends of an edge are in the cut then the edge is in the cut, and similarly if all four corners of a face are in the cut then the face is in the cut. 32 | 33 | I start with lattice points near the offset position and perform a depth-first traversal of the lattice to find all points that are in the cut and in the view area. Then for each of these points, I check its neighbors to determine if enough are in the cut to form a face. 34 | 35 | Cut testing: a lattice point is in the cut if the cutting plane passes through the axis-aligned unit hypercube centered on the point. We can also flip this around by centering the hypercube on the plane and saying: if there exists an axis-aligned hypercube whose center lies on the cutting plane that contains a lattice point, then that point is in the cut. 36 | 37 | Visualize sliding a hypercube around on the cutting plane. As it slides around, it will cover some of the lattice points. If there is any place we can slide the hypercube to cover a given lattice point, then that point is in the cut. 38 | 39 | Another way to visualize this is to to project the hypercube and lattice points onto the space *dual* to the cutting plane, which is the (n-2)-dimensional space orthogonal to it. If the projection of a lattice point onto the dual space falls within the projection of the unit hypercube onto the dual space, then there is somewhere we could slide the hypercube to cover that lattice point, and hence it is in the cut. 40 | 41 | So we need to test if a point falls inside the projection, or *shadow*, of a hypercube in the dual space. The hypercube shadow forms a region bounded by pairs of parallel hyperplanes in the dual space. A point must be between every pair of bounding hyperplanes to be inside the shadow. 42 | 43 | Now we have reduced the problem to testing whether a point falls between a pair of parallel hyperplanes. Represent each hyperplane pair by the unique vector (in the dual space) orthogonal to them, which I will refer to as a *check axis*. Projecting each vertex of the hypercube onto a check axis will determine a minimum and maximum bound for that check axis. To test a lattice point, project it onto each check axis and test whether it falls between the minimum and maximum for that check axis. 44 | 45 | For a 2D cutting plane in an n-D space, the dual space will have dimension n-2, which means the hyperplanes will have dimension n-3. Each (n-3)-D hyperplane corresponds to an (n-3)-D surface of the hypercube. There are n choose (n-3) orientations of (n-3)-D surface in the hypercube, so we must find and test n choose (n-3) = n choose 3 check axes. 46 | 47 | | Lattice dimension | # of check axes | 48 | |:-----------------:|:---------------:| 49 | | 3 | 1 | 50 | | 4 | 4 | 51 | | 5 | 10 | 52 | | 6 | 20 | 53 | | 7 | 35 | 54 | 55 | *TODO diagrams* 56 | 57 | #### Multigrid 58 | 59 | The multigrid method directly produces the faces of the tiling without even needing to consider faces (or points or edges) that might be outside it. As a result, it is much faster than cut-and-project. 60 | 61 | For our purposes, an n-dimensional multigrid consists of n sets of parallel, evenly spaced lines. Every intersection of two grid lines corresponds to a face in the tiling. So to generate the tiling, we can iterate over each pair of grid lines that can intersect, find the intersection point, and then add that face to the tiling. 62 | 63 | Sometimes, three (or more) grid lines intersect in the same point. De Bruijn calls a pentagrid *singular* when this happens; a pentagrid in which at most two grid lines intersect at any point is called *regular*. Usually singular grids are simply avoided, but with some care we can still generate a consistent tiling. *TODO explain* 64 | 65 | ## References 66 | 67 | - Nice overview in a pair of articles from the American Mathematical Society about Penrose tilings. Explains different methods of creating them such as inflation-deflation, de Bruijn's pentagrid method, and the projection method. 68 | - [Penrose Tiles Talk Across Miles](http://www.ams.org/publicoutreach/feature-column/fcarc-penrose) 69 | - [Penrose Tilings Tied up in Ribbons](http://www.ams.org/publicoutreach/feature-column/fcarc-ribbons) 70 | 71 | - [Quasitiler](http://www.geom.uiuc.edu/apps/quasitiler/about.html) by Eugenio Durand 72 | 73 | - [deBruijn](http://www.gregegan.net/APPLETS/12/12.html) applet by Greg Egan. Accompanying [explanation](http://www.gregegan.net/APPLETS/12/deBruijnNotes.html) helped me understand the correspondence between the cut-and-project and multigrid methods. 74 | 75 | - Wikipedia 76 | - [Penrose tiling](https://en.wikipedia.org/wiki/Penrose_tiling) 77 | - [Aperiodic tiling](https://en.wikipedia.org/wiki/Aperiodic_tiling) 78 | - [Quasicrystal](https://en.wikipedia.org/wiki/Quasicrystal) 79 | -------------------------------------------------------------------------------- /js/rendergl.js: -------------------------------------------------------------------------------- 1 | const VERTEX_PER_FACE = 6; 2 | 3 | export class RendererGL { 4 | constructor(gl) { 5 | this.gl = gl; 6 | this.programInfo = this.initShaders(5); 7 | } 8 | 9 | render(state, faces, scale) { 10 | if (this.programInfo.shaderDims !== state.dims) { 11 | // console.debug("recompiling shaders for", state.dims, "dims"); 12 | this.programInfo = this.initShaders(state.dims); 13 | } 14 | 15 | const buffers = this.initBuffers(VERTEX_PER_FACE * faces.length); 16 | this.drawFaces(buffers, faces, state); 17 | this.bufferData(buffers); 18 | 19 | const uniforms = this.initUniforms(state, scale); 20 | 21 | this.drawScene(buffers, uniforms, state.lineColor); 22 | } 23 | 24 | initShaders(dims) { 25 | // Vertex shader program 26 | const vsSource = ` 27 | const int MAX_DIMS = ${dims}; 28 | 29 | attribute vec2 aVertexPosition; 30 | attribute vec3 aVertexColor; 31 | attribute vec2 aFaceAxis; 32 | attribute vec2 aFacePosition; 33 | 34 | uniform vec2 uScalingFactor; 35 | uniform vec2 uAxis[MAX_DIMS]; 36 | 37 | varying lowp vec3 vColor; 38 | 39 | void main() { 40 | mat2 face_basis = mat2(uAxis[int(aFaceAxis[0])], uAxis[int(aFaceAxis[1])]); 41 | vec2 v = aFacePosition + face_basis * aVertexPosition; 42 | gl_Position = vec4(v * uScalingFactor, 0.0, 1.0); 43 | vColor = aVertexColor / 255.0; 44 | } 45 | `; 46 | 47 | // Fragment shader program 48 | const fsSource = ` 49 | varying lowp vec3 vColor; 50 | 51 | void main() { 52 | gl_FragColor = vec4(vColor, 1.0); 53 | } 54 | `; 55 | 56 | const shaderProgram = initShaderProgram(this.gl, vsSource, fsSource); 57 | 58 | return { 59 | program: shaderProgram, 60 | shaderDims: dims, 61 | attribLocations: { 62 | vertexPosition: this.gl.getAttribLocation(shaderProgram, 'aVertexPosition'), 63 | vertexColor: this.gl.getAttribLocation(shaderProgram, 'aVertexColor'), 64 | faceAxis: this.gl.getAttribLocation(shaderProgram, 'aFaceAxis'), 65 | facePosition: this.gl.getAttribLocation(shaderProgram, 'aFacePosition'), 66 | }, 67 | uniformLocations: { 68 | scalingFactor: this.gl.getUniformLocation(shaderProgram, 'uScalingFactor'), 69 | axis: this.gl.getUniformLocation(shaderProgram, 'uAxis'), 70 | }, 71 | }; 72 | } 73 | 74 | initBuffers(vertexCount) { 75 | return { 76 | vertexCount, 77 | vertexPosition: new GLAttributeBuffer(this.gl, vertexCount, 2), 78 | color: new GLAttributeBuffer(this.gl, vertexCount, 3), 79 | axis: new GLAttributeBuffer(this.gl, vertexCount, 2), 80 | facePos: new GLAttributeBuffer(this.gl, vertexCount, 2), 81 | }; 82 | } 83 | 84 | bufferData(buffers) { 85 | buffers.vertexPosition.bufferData(); 86 | buffers.color.bufferData(); 87 | buffers.axis.bufferData(); 88 | buffers.facePos.bufferData(); 89 | } 90 | 91 | enableBuffers(buffers) { 92 | buffers.vertexPosition.enable(this.programInfo.attribLocations.vertexPosition); 93 | buffers.color.enable(this.programInfo.attribLocations.vertexColor); 94 | buffers.axis.enable(this.programInfo.attribLocations.faceAxis); 95 | buffers.facePos.enable(this.programInfo.attribLocations.facePosition); 96 | } 97 | 98 | initUniforms(state, scale) { 99 | const axis = []; 100 | for (let i = 0; i < state.dims; i++) { 101 | axis.push(state.basis[0][i]); 102 | axis.push(state.basis[1][i]); 103 | } 104 | 105 | return { 106 | axis, 107 | scalingFactor: [2*scale/this.gl.canvas.width, -2*scale/this.gl.canvas.height], 108 | }; 109 | } 110 | 111 | setUniforms(uniforms) { 112 | this.gl.uniform2fv(this.programInfo.uniformLocations.scalingFactor, uniforms.scalingFactor); 113 | this.gl.uniform2fv(this.programInfo.uniformLocations.axis, uniforms.axis); 114 | } 115 | 116 | drawFaces(buffers, faces, state) { 117 | const insets = state.getInsets(); 118 | faces.forEach((face) => { 119 | this.drawFace(buffers, face, state, insets); 120 | }); 121 | } 122 | 123 | drawFace(buffers, face, state, insets) { 124 | const color = state.getColor(face.axis1, face.axis2); 125 | 126 | // Helper function to add one vertex to the buffers. 127 | const pushVertex = (pos) => { 128 | buffers.vertexPosition.push(pos[0]); 129 | buffers.vertexPosition.push(pos[1]); 130 | buffers.color.push(color[0]); 131 | buffers.color.push(color[1]); 132 | buffers.color.push(color[2]); 133 | buffers.axis.push(face.axis1); 134 | buffers.axis.push(face.axis2); 135 | buffers.facePos.push(face.keyVert[0]); 136 | buffers.facePos.push(face.keyVert[1]); 137 | }; 138 | 139 | // Inset vertex positions for this face. 140 | const inset1 = insets[face.axis1][face.axis2]; 141 | const inset2 = insets[face.axis2][face.axis1]; 142 | const vertPos = [ 143 | [0 + inset1, 0 + inset2], 144 | [1 - inset1, 0 + inset2], 145 | [1 - inset1, 1 - inset2], 146 | [0 + inset1, 1 - inset2], 147 | ]; 148 | 149 | pushVertex(vertPos[0]); 150 | pushVertex(vertPos[1]); 151 | pushVertex(vertPos[2]); 152 | 153 | pushVertex(vertPos[0]); 154 | pushVertex(vertPos[2]); 155 | pushVertex(vertPos[3]); 156 | } 157 | 158 | drawScene(buffers, uniforms, bgColor) { 159 | const gl = this.gl; 160 | 161 | // Set GL viewport. Do this every time because canvas can be resized. 162 | gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); 163 | 164 | // Clear the canvas before we start drawing on it. 165 | gl.clearColor(bgColor[0], bgColor[1], bgColor[2], 1.0); 166 | gl.clearDepth(1.0); 167 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); 168 | 169 | // Tell WebGL how to pull out the attributes from the attribute buffers. 170 | this.enableBuffers(buffers); 171 | 172 | // Tell WebGL to use our program when drawing 173 | gl.useProgram(this.programInfo.program); 174 | 175 | // Set the shader uniforms 176 | this.setUniforms(uniforms); 177 | 178 | const offset = 0; 179 | gl.drawArrays(gl.TRIANGLES, offset, buffers.vertexCount); 180 | } 181 | } 182 | 183 | // 184 | // Initialize a shader program, so WebGL knows how to draw our data. 185 | // Source: MDN WebGL tutorial, "Adding 2D content to a WebGL context" 186 | // 187 | function initShaderProgram(gl, vsSource, fsSource) { 188 | const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); 189 | const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); 190 | 191 | // Create the shader program 192 | const shaderProgram = gl.createProgram(); 193 | gl.attachShader(shaderProgram, vertexShader); 194 | gl.attachShader(shaderProgram, fragmentShader); 195 | gl.linkProgram(shaderProgram); 196 | 197 | // If creating the shader program failed, alert 198 | if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { 199 | alert('Unable to initialize the shader program: ' + gl.getProgramInfoLog(shaderProgram)); 200 | return null; 201 | } 202 | 203 | return shaderProgram; 204 | } 205 | 206 | // 207 | // Creates a shader of the given type, uploads the source and compiles it. 208 | // Source: MDN WebGL tutorial, "Adding 2D content to a WebGL context" 209 | // 210 | function loadShader(gl, type, source) { 211 | const shader = gl.createShader(type); 212 | 213 | // Send the source to the shader object 214 | gl.shaderSource(shader, source); 215 | 216 | // Compile the shader program 217 | gl.compileShader(shader); 218 | 219 | // See if it compiled successfully 220 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 221 | alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)); 222 | gl.deleteShader(shader); 223 | return null; 224 | } 225 | 226 | return shader; 227 | } 228 | 229 | class GLAttributeBuffer { 230 | constructor(gl, numElems, numComponents) { 231 | this.gl = gl; 232 | this.numComponents = numComponents; 233 | this.type = gl.FLOAT; 234 | this.normalize = false; 235 | this.stride = 0; 236 | this.offset = 0; 237 | this.buffer = gl.createBuffer(); 238 | this.array = new Float32Array(numElems * numComponents); 239 | this.ix = 0; 240 | } 241 | 242 | bufferData() { 243 | this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer); 244 | this.gl.bufferData(this.gl.ARRAY_BUFFER, this.array, this.gl.STATIC_DRAW); 245 | } 246 | 247 | enable(attribLoc) { 248 | this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer); 249 | this.gl.vertexAttribPointer( 250 | attribLoc, 251 | this.numComponents, 252 | this.type, 253 | this.normalize, 254 | this.stride, 255 | this.offset); 256 | this.gl.enableVertexAttribArray(attribLoc); 257 | } 258 | 259 | push(value) { 260 | if (this.ix < this.array.length) { 261 | this.array[this.ix++] = value; 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /js/cutproject.js: -------------------------------------------------------------------------------- 1 | import { TilingView, TilingViewState, loadStateCode } from './tilingview.js'; 2 | import { AxisControls, OffsetControls } from './controls.js'; 3 | import { generateCutProj, generateMultigrid } from './tiling.js'; 4 | import { generateWasm } from './tiling_wasm.js'; 5 | import { rotate } from './vector.js'; 6 | import { makeColor, splitColor } from './statecode.js'; 7 | 8 | class TilingApp { 9 | constructor() { 10 | const numAxes = document.getElementById('numAxes'); 11 | numAxes.addEventListener('change', () => { 12 | this.setState(new TilingViewState(parseInt(numAxes.value, 10))); 13 | }); 14 | const dims = parseInt(numAxes.value, 10); 15 | this.state = new TilingViewState(dims); 16 | 17 | const canvas = document.getElementById('main'); 18 | this.tilingView = new TilingView(canvas, this); 19 | 20 | const methodPicker = document.getElementById('tileGen'); 21 | methodPicker.addEventListener('change', () => { 22 | this.tilingGen = this.getTilingGen(methodPicker.value); 23 | this.redraw(); 24 | }); 25 | this.tilingGen = this.getTilingGen(methodPicker.value); 26 | 27 | const axisCanvas = document.getElementById('axisRosette'); 28 | this.axisControls = new AxisControls(axisCanvas, this, dims); 29 | this.updateAxisControls(); 30 | attachToggle('axisToggle', 'axisControls'); 31 | 32 | const offsetsDiv = document.getElementById('offsetControls'); 33 | this.offsetControls = new OffsetControls(offsetsDiv, this, dims); 34 | this.updateOffsetControls(); 35 | attachToggle('offsetToggle', 'offsetControls'); 36 | document.getElementById('offsetControls').style.display = 'none'; 37 | 38 | const resetBtn = document.getElementById('reset'); 39 | resetBtn.addEventListener('click', () => { 40 | this.state.resetBasis(); 41 | this.updateAxisControls(); 42 | this.redraw(); 43 | }); 44 | 45 | this.initColorControls(dims); 46 | attachToggle('colorToggle', 'colorControls'); 47 | document.getElementById('colorControls').style.display = 'none'; 48 | 49 | attachToggle('aboutToggle', 'about'); 50 | document.getElementById('about').style.display = 'none'; 51 | 52 | this.animating = false; 53 | this.animTime = -1; 54 | const animateBtn = document.getElementById('animate'); 55 | animateBtn.addEventListener('click', () => { 56 | if (!this.animating) { 57 | this.startAnimation(); 58 | animateBtn.value = 'Stop animation'; 59 | } else { 60 | this.stopAnimation(); 61 | animateBtn.value = 'Animate'; 62 | } 63 | }); 64 | 65 | const saveBtn = document.getElementById('save'); 66 | saveBtn.addEventListener('click', () => { 67 | this.state.genStateCode((code) => { 68 | const codeCode = document.getElementById('codeCode'); 69 | codeCode.innerHTML = code; 70 | window.getSelection().selectAllChildren(codeCode); 71 | const codeLink = document.getElementById('codeLink'); 72 | codeLink.href = '?a=' + code; 73 | const codeShow = document.getElementById('codeShow'); 74 | codeShow.classList.add('codeActive'); 75 | }); 76 | }); 77 | 78 | const codeDoneBtn = document.getElementById('codeDone'); 79 | codeDoneBtn.addEventListener('click', () => { 80 | const codeShow = document.getElementById('codeShow'); 81 | codeShow.classList.remove('codeActive'); 82 | }); 83 | 84 | const codeInput = document.getElementById('codeInput'); 85 | codeInput.addEventListener('change', () => { 86 | console.log('loading state code:', codeInput.value); 87 | codeInput.blur(); 88 | loadStateCode(codeInput.value, (st) => this.setState(st)); 89 | }); 90 | codeInput.addEventListener('blur', () => { 91 | const codeLoad = document.getElementById('codeLoad'); 92 | codeLoad.classList.remove('codeActive'); 93 | }); 94 | 95 | const loadBtn = document.getElementById('load'); 96 | loadBtn.addEventListener('click', () => { 97 | codeInput.value = ''; 98 | codeInput.focus(); 99 | const codeLoad = document.getElementById('codeLoad'); 100 | codeLoad.classList.add('codeActive'); 101 | }); 102 | 103 | this.draw(); 104 | this.needsRedraw = false; 105 | } 106 | 107 | getTilingGen(method) { 108 | if (method === 'project') { 109 | return generateCutProj; 110 | } else if (method === 'multigrid') { 111 | return generateMultigrid; 112 | } else if (method == 'wasm') { 113 | return generateWasm; 114 | } else { 115 | console.error("unknown tiling generator type:", method); 116 | return generateMultigrid; 117 | } 118 | } 119 | 120 | initColorControls(dims) { 121 | // Use width of main control box because the color controls might be hidden. 122 | const controlsWidth = document.getElementById('controls').clientWidth; 123 | const width = Math.floor(controlsWidth / (dims - 1)); 124 | const widthStyle = `${width}px`; 125 | 126 | const colorsDiv = document.getElementById('colorControls'); 127 | let colorIx = 0; 128 | for (let row = 0; row < dims - 1; row++) { 129 | let rowDiv = document.createElement('div'); 130 | colorsDiv.append(rowDiv); 131 | for (let col = 0; col < dims - row - 1; col++) { 132 | const colorIxHere = colorIx++; 133 | const ctl = document.createElement('input'); 134 | ctl.type = 'color'; 135 | ctl.value = makeColor(this.state.colors[colorIxHere]); 136 | ctl.style.width = widthStyle; 137 | ctl.addEventListener('input', () => { 138 | this.state.colors[colorIxHere] = splitColor(ctl.value); 139 | this.redraw(); 140 | }); 141 | rowDiv.prepend(ctl); 142 | } 143 | } 144 | } 145 | 146 | removeColorControls() { 147 | const colorsDiv = document.getElementById('colorControls'); 148 | const divClone = colorsDiv.cloneNode(false); 149 | colorsDiv.parentNode.replaceChild(divClone, colorsDiv); 150 | } 151 | 152 | draw() { 153 | this.tilingView.draw(this.state, this.tilingGen); 154 | this.axisControls.draw(); 155 | } 156 | 157 | redraw() { 158 | if (this.needsRedraw) return; 159 | this.needsRedraw = true; 160 | window.requestAnimationFrame(() => { 161 | this.needsRedraw = false; 162 | this.draw(); 163 | }); 164 | } 165 | 166 | updateAxisControls() { 167 | // Move axis controls to match the basis vectors. 168 | this.axisControls.ctls.forEach((ctl, i) => { 169 | ctl.x = this.state.basis[0][i]; 170 | ctl.y = this.state.basis[1][i]; 171 | }); 172 | } 173 | 174 | axisChanged(changeAxis, x, y) { 175 | this.state.moveAxis(changeAxis, x, y); 176 | this.updateAxisControls(); 177 | this.redraw(); 178 | } 179 | 180 | updateOffsetControls() { 181 | this.state.offset.forEach((offset, i) => { 182 | this.offsetControls.setOffset(i, offset); 183 | }); 184 | } 185 | 186 | offsetChanged(i, value) { 187 | this.state.offset[i] = value; 188 | this.redraw(); 189 | } 190 | 191 | translateOffset(dx, dy) { 192 | this.state.translateOffset(dx, dy); 193 | this.updateOffsetControls(); 194 | this.redraw(); 195 | } 196 | 197 | setState(state) { 198 | this.state = state; 199 | 200 | document.getElementById('numAxes').value = this.state.dims; 201 | this.axisControls.setNumAxes(this.state.dims); 202 | this.updateAxisControls(); 203 | 204 | this.offsetControls.setNumAxes(this.state.dims); 205 | this.updateOffsetControls(); 206 | 207 | this.removeColorControls(); 208 | this.initColorControls(this.state.dims); 209 | 210 | this.redraw(); 211 | } 212 | 213 | startAnimation() { 214 | this.animating = true; 215 | this.animTime = -1; 216 | window.requestAnimationFrame((t) => this.animate(t)); 217 | } 218 | 219 | stopAnimation() { 220 | this.animating = false; 221 | } 222 | 223 | animate(timestamp) { 224 | if (!this.animating) return; 225 | const dt = (this.animTime >= 0) ? timestamp - this.animTime : 0; 226 | this.animTime = timestamp; 227 | const theta = 2*Math.PI / 3e4 * dt; 228 | rotate(this.state.basis[0], 0, 1, theta); 229 | rotate(this.state.basis[1], 0, 1, theta); 230 | this.updateAxisControls(); 231 | this.draw(); 232 | window.requestAnimationFrame((t) => this.animate(t)); 233 | } 234 | } 235 | 236 | function attachToggle(toggleID, panelID) { 237 | const toggle = document.getElementById(toggleID); 238 | toggle.addEventListener('click', () => { 239 | toggle.classList.toggle('active'); 240 | const panel = document.getElementById(panelID); 241 | if (panel.style.display === 'none') { 242 | panel.style.display = 'block'; 243 | } else { 244 | panel.style.display = 'none'; 245 | } 246 | }); 247 | } 248 | 249 | function init() { 250 | const app = new TilingApp(); 251 | 252 | const params = new URLSearchParams(document.location.search); 253 | if (params.has('a')) { 254 | const stateCode = params.get('a').replace(/ /g, '+'); 255 | console.log('initialising from state code:', stateCode); 256 | loadStateCode(stateCode, (st) => app.setState(st)); 257 | } 258 | } 259 | 260 | if (document.readyState === "loading") { 261 | document.addEventListener('DOMContentLoaded', () => init); 262 | } else { 263 | init(); 264 | } 265 | -------------------------------------------------------------------------------- /crate/src/multigrid.rs: -------------------------------------------------------------------------------- 1 | use std::f64::EPSILON; 2 | use std::f64::consts::FRAC_1_SQRT_2; 3 | use nalgebra::{DVector, RowDVector, Vector2, MatrixMN, Dynamic, U2}; 4 | use wasm_bindgen::prelude::*; 5 | 6 | use crate::{FaceList, Face, set_panic_hook}; 7 | 8 | struct State { 9 | dims: usize, 10 | basis: MatrixMN, 11 | offset: DVector, 12 | ext_prod: Vec>, 13 | } 14 | 15 | impl State { 16 | fn new(dims: usize, basis0: &[f64], basis1: &[f64], offset: &[f64]) -> Self { 17 | let basis = MatrixMN::from_rows(&[ 18 | RowDVector::from_row_slice(basis0), 19 | RowDVector::from_row_slice(basis1), 20 | ]); 21 | let ext_prod = get_ext_products(&basis); 22 | State { 23 | basis, 24 | dims, 25 | offset: DVector::from_column_slice(offset), 26 | ext_prod, 27 | } 28 | } 29 | 30 | // Project a point in space onto the view plane. 31 | fn project(&self, v: DVector) -> Vector2 { 32 | &self.basis * (v - &self.offset) 33 | } 34 | 35 | // Take a point on the view plane to the corresponding point in space. 36 | fn unproject(&self, p: Vector2) -> DVector { 37 | self.basis.tr_mul(&p) + &self.offset 38 | } 39 | 40 | // Find the range of grid lines for each axis. 41 | fn get_grid_ranges(&self, hbound: f64, vbound: f64) -> (Vec, Vec) { 42 | let corners = [ 43 | self.unproject(Vector2::new(-hbound, -vbound)), 44 | self.unproject(Vector2::new(-hbound, vbound)), 45 | self.unproject(Vector2::new( hbound, -vbound)), 46 | self.unproject(Vector2::new( hbound, vbound)), 47 | ]; 48 | let mut grid_min = vec![std::i32::MAX; self.dims]; 49 | let mut grid_max = vec![std::i32::MIN; self.dims]; 50 | corners.iter().for_each(|corner| { 51 | corner.iter().enumerate().for_each(|(i, x)| { 52 | grid_min[i] = grid_min[i].min(x.floor() as i32); 53 | grid_max[i] = grid_max[i].max(x.ceil() as i32); 54 | }); 55 | }); 56 | (grid_min, grid_max) 57 | } 58 | 59 | fn get_face_vertex(&self, a1: usize, a2: usize, k1: f64, k2: f64) -> DVector { 60 | // Find the intersection (a,b) of the grid lines k1 and k2. 61 | let u = k1 + 0.5 - self.offset[a1]; 62 | let v = k2 + 0.5 - self.offset[a2]; 63 | let a = (u*self.basis[(1,a2)] - v*self.basis[(1,a1)]) / self.ext_prod[a1][a2]; 64 | let b = (v*self.basis[(0,a1)] - u*self.basis[(0,a2)]) / self.ext_prod[a1][a2]; 65 | 66 | // Find the coordinates of the key vertex for the face 67 | // corresponding to this intersection. 68 | self.unproject(Vector2::new(a, b)).map_with_location(|ax, _, x| { 69 | // Round each component of the unprojected vector to an integer. 70 | // Here we round the value x corresponding with axis ax. 71 | 72 | // If ax is a1 or a2, then we can just use the known grid line that 73 | // was given as a function parameter. 74 | if ax == a1 { 75 | k1 76 | } else if ax == a2 { 77 | k2 78 | 79 | // Next, check for singular multigrid. 80 | // 81 | // The unprojected point is the intersection of a grid line from axis a1 82 | // and a grid line from axis a2. We need to check whether the nearest 83 | // grid line from axis ax also passes through this intersection. If it does, 84 | // the multigrid is called "singular" and the tiling is ambiguous. 85 | // 86 | // The intersection is on a grid line of axis ax if x has a fractional 87 | // part of 0.5. The ambiguity of the tiling corresponds to the ambiguity 88 | // of rounding up or down for a number with fractional part 0.5. 89 | // We resolve the ambiguity in the following way: if axis ax lies between 90 | // axis a1 and axis a2 on the cutting plane, then round up. 91 | // Otherwise, round down. 92 | } else if (x - x.floor() - 0.5).abs() > 1e-10 { 93 | // Normal (non-singular) case. 94 | x.round() 95 | } else if self.axis_between(a1, a2, ax) { 96 | x.ceil() 97 | } else { 98 | x.floor() 99 | } 100 | }) 101 | } 102 | 103 | // Test whether axis ax is between axes a1 and a2. 104 | // We can assume that a1 != ax != a2 and that axes a1 and a2 are not parallel. 105 | fn axis_between(&self, a1: usize, a2: usize, ax: usize) -> bool { 106 | if self.ext_prod[a1][ax].abs() < EPSILON { 107 | // Axes a1 and ax are parallel. Consider ax to lie between 108 | // if a1 and ax point the same direction AND ax < a1. 109 | self.basis.column(a1).dot(&self.basis.column(ax)) > 0.0 && ax < a1 110 | } else if self.ext_prod[ax][a2].abs() < EPSILON { 111 | // Axis ax and a2 are parallel. Consider ax to lie between 112 | // if ax and a2 point the same direction AND ax < a2. 113 | self.basis.column(ax).dot(&self.basis.column(a2)) > 0.0 && ax < a2 114 | } else { 115 | // Axis ax lies between axis a1 and axis a2 if the rotation from 116 | // a1 to a2 is the same as a1 to ax and ax to a2. 117 | self.ext_prod[a1][a2]*self.ext_prod[a1][ax] > 0.0 118 | && self.ext_prod[a1][a2]*self.ext_prod[ax][a2] > 0.0 119 | } 120 | } 121 | } 122 | 123 | // It will be useful to have the exterior products (aka perp dot product) 124 | // of each pair of axes. 125 | fn get_ext_products(basis: &MatrixMN) -> Vec> { 126 | let mut prods = vec![vec![0.0; basis.ncols()]; basis.ncols()]; 127 | for i in 1..basis.ncols() { 128 | for j in 0..i { 129 | prods[i][j] = basis.column(i).perp(&basis.column(j)); 130 | prods[j][i] = -prods[i][j]; 131 | } 132 | } 133 | prods 134 | } 135 | 136 | #[wasm_bindgen] 137 | pub fn generate( 138 | dims: usize, 139 | basis0: &[f64], 140 | basis1: &[f64], 141 | offset: &[f64], 142 | view_width: f64, 143 | view_height: f64, 144 | ) -> FaceList { 145 | set_panic_hook(); 146 | 147 | let state = State::new(dims, basis0, basis1, offset); 148 | let hbound = view_width/2.0 + FRAC_1_SQRT_2; 149 | let vbound = view_height/2.0 + FRAC_1_SQRT_2; 150 | let (grid_min, grid_max) = state.get_grid_ranges(hbound, vbound); 151 | 152 | let mut faces = Vec::new(); 153 | for i in 0..dims - 1 { 154 | for j in i+1..dims { 155 | if state.ext_prod[i][j].abs() < EPSILON { 156 | // Axis i and axis j are parallel. 157 | // Faces with this orientation have zero area / are perpendicular 158 | // to the cut plane, so they do not produce tiles. 159 | continue; 160 | } 161 | 162 | for ki in grid_min[i]..grid_max[i] { 163 | for kj in grid_min[j]..grid_max[j] { 164 | let face_vert = state.get_face_vertex(i, j, f64::from(ki), f64::from(kj)); 165 | let f1 = state.project(face_vert); 166 | 167 | let mid = f1 + (state.basis.column(i) + state.basis.column(j)) / 2.0; 168 | if mid[0].abs() > hbound || mid[1].abs() > vbound { 169 | continue; 170 | } 171 | 172 | faces.push( Face { 173 | key_vert_x: f1[0], 174 | key_vert_y: f1[1], 175 | axis1: i as u16, 176 | axis2: j as u16, 177 | }); 178 | } 179 | } 180 | } 181 | } 182 | FaceList { faces } 183 | } 184 | 185 | #[cfg(test)] 186 | mod test { 187 | use super::*; 188 | 189 | #[test] 190 | fn generate_penrose() { 191 | let dims = 5; 192 | let f = (2.0/dims as f64).sqrt(); 193 | let g = (dims % 2 + 1) as f64 * std::f64::consts::PI / dims as f64; 194 | let basis0: Vec<_> = (0..dims).map(|x| f * (g * x as f64).cos()).collect(); 195 | let basis1: Vec<_> = (0..dims).map(|x| f * (g * x as f64).sin()).collect(); 196 | let offset: Vec<_> = (0..dims).map(|_| 0.3).collect(); 197 | let view_width = 10.0; 198 | let view_height = 10.0; 199 | let faces = generate(dims, &basis0, &basis1, &offset, view_width, view_height); 200 | assert_eq!(406, faces.get_num_faces()); 201 | } 202 | 203 | #[test] 204 | fn generate_singular() { 205 | // Test singular multigrid. 206 | let dims = 5; 207 | let f = (2.0/dims as f64).sqrt(); 208 | let g = (dims % 2 + 1) as f64 * std::f64::consts::PI / dims as f64; 209 | let basis0: Vec<_> = (0..dims).map(|x| f * (g * x as f64).cos()).collect(); 210 | let basis1: Vec<_> = (0..dims).map(|x| f * (g * x as f64).sin()).collect(); 211 | let offset: Vec<_> = (0..dims).map(|_| 0.5).collect(); 212 | let view_width = 10.0; 213 | let view_height = 10.0; 214 | let faces = generate(dims, &basis0, &basis1, &offset, view_width, view_height); 215 | assert_eq!(416, faces.get_num_faces()); 216 | } 217 | 218 | #[test] 219 | fn generate_singular_a() { 220 | // Test singular case A -- parallel axes #1. 221 | let basis0 = [0.0, 0.0, 1.0]; 222 | let basis1 = [FRAC_1_SQRT_2, FRAC_1_SQRT_2, 0.0]; 223 | let offset = [0.0; 3]; 224 | let state = State::new(3, &basis0, &basis1, &offset); 225 | let face_vert = state.get_face_vertex(1, 2, 0.0, 0.0); 226 | assert_eq!(face_vert, DVector::from_column_slice(&[1.0, 0.0, 0.0])); 227 | } 228 | 229 | #[test] 230 | fn generate_singular_b() { 231 | // Test singular case B -- parallel axes #2. 232 | let basis0 = [1.0, 0.0, 0.0]; 233 | let basis1 = [0.0, FRAC_1_SQRT_2, FRAC_1_SQRT_2]; 234 | let offset = [0.0; 3]; 235 | let state = State::new(3, &basis0, &basis1, &offset); 236 | let face_vert = state.get_face_vertex(0, 2, 0.0, 0.0); 237 | assert_eq!(face_vert, DVector::from_column_slice(&[0.0, 1.0, 0.0])); 238 | } 239 | 240 | #[test] 241 | fn generate_singular_c() { 242 | // Test singular case C -- axis between non-parallel axes. 243 | let f = (2.0f64/3.0).sqrt(); 244 | let basis0 = [f, f/2.0, f/2.0]; 245 | let basis1 = [0.0, FRAC_1_SQRT_2, -FRAC_1_SQRT_2]; 246 | let offset = [0.5; 3]; 247 | let state = State::new(3, &basis0, &basis1, &offset); 248 | let face_vert = state.get_face_vertex(1, 2, 0.0, 0.0); 249 | assert_eq!(face_vert, DVector::from_column_slice(&[1.0, 0.0, 0.0])); 250 | } 251 | 252 | #[test] 253 | fn generate_singular_d() { 254 | // Test singular case D -- axis not between non-parallel axes. 255 | let f = (2.0f64/3.0).sqrt(); 256 | let basis0 = [f, f/2.0, f/2.0]; 257 | let basis1 = [0.0, FRAC_1_SQRT_2, -FRAC_1_SQRT_2]; 258 | let offset = [0.5; 3]; 259 | let state = State::new(3, &basis0, &basis1, &offset); 260 | let face_vert = state.get_face_vertex(0, 1, 0.0, 0.0); 261 | assert_eq!(face_vert, DVector::from_column_slice(&[0.0, 0.0, 0.0])); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /js/tilingview.js: -------------------------------------------------------------------------------- 1 | import { TilingState } from './tiling.js'; 2 | import { encodeState, decodeState, base64ToBlob, makeColor } from './statecode.js'; 3 | import { RendererGL } from './rendergl.js'; 4 | 5 | const GRID_SCALE_INIT = 60; 6 | const GRID_SCALE_MIN = 30; 7 | const GRID_SCALE_MAX = 290; 8 | const LINE_WIDTH_INIT = 2/GRID_SCALE_INIT; 9 | const LINE_COLOR_INIT = [0,0,0]; 10 | 11 | export class TilingViewState extends TilingState { 12 | constructor(dims) { 13 | super(dims); 14 | this.initColors(dims); 15 | this.faceTypes = getFaceTypes(dims); 16 | this.lineColor = LINE_COLOR_INIT; 17 | this.lineWidth = LINE_WIDTH_INIT; 18 | } 19 | 20 | initColors(dims) { 21 | if (dims === 3) { 22 | this.colors = [[128,128,128], [232,232,255], [180,180,192]]; 23 | return; 24 | } 25 | 26 | let max = 0.0; 27 | let min = 1.0; 28 | for (let i = 1; i < dims; i++) { 29 | const x = Math.abs(this.basis[0][0]*this.basis[0][i] + this.basis[1][0]*this.basis[1][i]); 30 | max = Math.max(max, x); 31 | min = Math.min(min, x); 32 | } 33 | this.colors = []; 34 | for (let i = 0; i < dims-1; i++) { 35 | for (let j = i+1; j < dims; j++) { 36 | const dot_ij = this.basis[0][i]*this.basis[0][j] + this.basis[1][i]*this.basis[1][j]; 37 | const x = (Math.abs(dot_ij) - min) / (max - min); 38 | const r = 0xe8 - 0x68*x; 39 | const g = 0xe8 - 0x68*x; 40 | const b = 0xff - 0x7f*x; 41 | this.colors.push([r,g,b]); 42 | } 43 | } 44 | } 45 | 46 | getColor(axis1, axis2) { 47 | return this.colors[this.faceTypes[axis1][axis2]]; 48 | } 49 | 50 | getInsets() { 51 | const lineWidth = this.lineWidth / 2; 52 | const insets = []; 53 | for (let i = 0; i < this.dims; i++) { 54 | insets.push([]); 55 | } 56 | 57 | for (let i = 0; i < this.dims; i++) { 58 | const s0 = this.basis[0][i]; 59 | const s1 = this.basis[1][i]; 60 | for (let j = 0; j < this.dims; j++) { 61 | if (i === j) { 62 | insets[i][j] = 0; 63 | } else { 64 | const t0 = this.basis[0][j]; 65 | const t1 = this.basis[1][j]; 66 | insets[i][j] = lineWidth * Math.hypot(t0, t1) / Math.abs(s0*t1 - s1*t0); 67 | insets[i][j] = Math.min(insets[i][j], 0.5); 68 | } 69 | } 70 | } 71 | return insets; 72 | } 73 | 74 | genStateCode(contF) { 75 | const blob = encodeState(this); 76 | const reader = new FileReader(); 77 | reader.addEventListener("loadend", () => { 78 | const code = reader.result.split(',')[1]; 79 | console.log('generated state code:', code); 80 | contF(code); 81 | }, false); 82 | reader.readAsDataURL(blob); 83 | } 84 | } 85 | 86 | export function loadStateCode(stateCode, contF) { 87 | const blob = base64ToBlob(stateCode); 88 | const reader = new FileReader(); 89 | reader.addEventListener("loadend", () => { 90 | const st = decodeState(reader.result); 91 | if (st) { 92 | const state = new TilingViewState(st.dims); 93 | state.basis = st.basis; 94 | state.offset = st.offset; 95 | state.colors = st.colors; 96 | state.lineColor = st.lineColor; 97 | state.validate(); 98 | contF(state); 99 | } else { 100 | console.error('Failed decoding state code.'); 101 | } 102 | }, false); 103 | reader.readAsArrayBuffer(blob); 104 | } 105 | 106 | function getFaceTypes(dims) { 107 | const faceType = []; 108 | for (let i = 0; i < dims; i++) { 109 | faceType.push([]); 110 | } 111 | 112 | let typeIx = 0; 113 | for (let i = 0; i < dims-1; i++) { 114 | for (let j = i+1; j < dims; j++) { 115 | faceType[i][j] = typeIx; 116 | faceType[j][i] = typeIx; 117 | typeIx += 1; 118 | } 119 | } 120 | return faceType; 121 | } 122 | 123 | export class TilingView { 124 | constructor(canvas, app) { 125 | this.canvas = canvas; 126 | this.app = app; 127 | this.tracking = null; 128 | this.scale = GRID_SCALE_INIT; 129 | 130 | canvas.width = canvas.clientWidth; 131 | canvas.height = canvas.clientHeight; 132 | 133 | canvas.addEventListener('mousedown', this, false); 134 | canvas.addEventListener('mousemove', this, false); 135 | canvas.addEventListener('mouseup', this, false); 136 | canvas.addEventListener('touchstart', this, false); 137 | canvas.addEventListener('touchmove', this, false); 138 | canvas.addEventListener('touchend', this, false); 139 | canvas.addEventListener('wheel', this, {passive: true}); 140 | window.addEventListener('resize', this, false); 141 | 142 | const gl = this.canvas.getContext("webgl"); 143 | if (gl !== null) { 144 | this.renderer = new RendererGL(gl); 145 | } else { 146 | const ctx = this.canvas.getContext('2d'); 147 | if (ctx !== null) { 148 | this.renderer = new Renderer2D(ctx); 149 | } else { 150 | this.renderer = { 151 | render() { 152 | console.error("no rendering context"); 153 | } 154 | }; 155 | } 156 | } 157 | } 158 | 159 | draw(state, tilingGen) { 160 | const viewWidth = this.canvas.width / this.scale; 161 | const viewHeight = this.canvas.height / this.scale; 162 | const faces = tilingGen(state, viewWidth, viewHeight); 163 | this.renderer.render(state, faces, this.scale); 164 | } 165 | 166 | handleEvent(event) { 167 | switch (event.type) { 168 | case 'mousedown': 169 | this.tracking = { x: event.offsetX, y: event.offsetY }; 170 | break; 171 | case 'mousemove': 172 | if (this.tracking) { 173 | this.mouseMove(event.offsetX, event.offsetY); 174 | } 175 | break; 176 | case 'mouseup': 177 | this.tracking = null; 178 | break; 179 | case 'touchstart': 180 | this.touchStart(event); 181 | break; 182 | case 'touchmove': 183 | this.touchMove(event); 184 | break; 185 | case 'touchend': 186 | this.tracking = null; 187 | break; 188 | case 'wheel': 189 | this.wheelMove(event); 190 | break; 191 | case 'resize': 192 | this.resize(); 193 | break; 194 | } 195 | } 196 | 197 | mouseMove(canvasX, canvasY) { 198 | this.viewMove(this.tracking.x - canvasX, this.tracking.y - canvasY); 199 | this.tracking.x = canvasX; 200 | this.tracking.y = canvasY; 201 | } 202 | 203 | wheelMove(event) { 204 | this.viewZoom(event.deltaY > 0 ? 0.8 : 1.25); 205 | } 206 | 207 | touchStart(event) { 208 | event.preventDefault(); 209 | if (!this.tracking) { 210 | this.tracking = []; 211 | } 212 | for (let i = 0; i < event.changedTouches.length; i++) { 213 | const touch = event.changedTouches[i]; 214 | if (!this.tracking.touch0) { 215 | this.tracking.touch0 = { 216 | id: touch.identifier, 217 | x: touch.pageX, 218 | y: touch.pageY 219 | }; 220 | } else if (!this.tracking.touch1) { 221 | this.tracking.touch1 = { 222 | id: touch.identifier, 223 | x: touch.pageX, 224 | y: touch.pageY 225 | }; 226 | this.tracking.dist = Math.hypot( 227 | this.tracking.touch0.x - this.tracking.touch1.x, 228 | this.tracking.touch0.y - this.tracking.touch1.y); 229 | } else { 230 | // Only track 2 touches. 231 | break; 232 | } 233 | } 234 | } 235 | 236 | touchMove(event) { 237 | if (this.tracking) { 238 | event.preventDefault(); 239 | let changed = false; 240 | for (let i = 0; i < event.changedTouches.length; i++) { 241 | const touch = event.changedTouches[i]; 242 | if (this.tracking.touch0.id === touch.identifier) { 243 | this.tracking.touch0.lastX = this.tracking.touch0.x; 244 | this.tracking.touch0.lastY = this.tracking.touch0.y; 245 | this.tracking.touch0.x = touch.pageX; 246 | this.tracking.touch0.y = touch.pageY; 247 | changed = true; 248 | } else if (this.tracking.touch1.id === touch.identifier) { 249 | this.tracking.touch1.x = touch.pageX; 250 | this.tracking.touch1.y = touch.pageY; 251 | changed = true; 252 | } 253 | } 254 | 255 | if (changed) { 256 | if (this.tracking.touch1) { 257 | const newdist = Math.hypot( 258 | this.tracking.touch0.x - this.tracking.touch1.x, 259 | this.tracking.touch0.y - this.tracking.touch1.y); 260 | if (this.tracking.dist !== 0) { 261 | this.viewZoom(newdist/this.tracking.dist); 262 | } 263 | this.tracking.dist = newdist; 264 | } else { 265 | this.viewMove( 266 | this.tracking.touch0.lastX - this.tracking.touch0.x, 267 | this.tracking.touch0.lastY - this.tracking.touch0.y); 268 | } 269 | } 270 | } 271 | } 272 | 273 | viewMove(dx, dy) { 274 | this.app.translateOffset(dx/this.scale, dy/this.scale); 275 | } 276 | 277 | viewZoom(dzoom) { 278 | const oldScale = this.scale; 279 | if (dzoom > 1) { 280 | this.scale = Math.min(GRID_SCALE_MAX, Math.ceil(this.scale * dzoom)); 281 | } else if (dzoom < 1) { 282 | this.scale = Math.max(GRID_SCALE_MIN, Math.floor(this.scale * dzoom)); 283 | } 284 | if (this.scale !== oldScale) { 285 | this.app.redraw(); 286 | } 287 | } 288 | 289 | resize() { 290 | this.canvas.width = this.canvas.clientWidth; 291 | this.canvas.height = this.canvas.clientHeight; 292 | this.app.redraw(); 293 | } 294 | } 295 | 296 | class Renderer2D { 297 | constructor(ctx) { 298 | this.ctx = ctx; 299 | } 300 | 301 | render(state, faces, scale) { 302 | const ctx = this.ctx; 303 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 304 | 305 | ctx.save(); 306 | ctx.translate(ctx.canvas.width / 2, ctx.canvas.height / 2); 307 | ctx.scale(scale, scale); 308 | 309 | ctx.lineWidth = state.lineWidth; 310 | ctx.lineJoin = 'bevel'; 311 | ctx.strokeStyle = makeColor(state.lineColor); 312 | faces.forEach((f) => { 313 | ctx.fillStyle = makeColor(state.getColor(f.axis1, f.axis2)); 314 | const side1 = [state.basis[0][f.axis1], state.basis[1][f.axis1]]; 315 | const side2 = [state.basis[0][f.axis2], state.basis[1][f.axis2]]; 316 | ctx.translate(f.keyVert[0], f.keyVert[1]); 317 | ctx.beginPath(); 318 | ctx.moveTo(0, 0); 319 | ctx.lineTo(side1[0], side1[1]); 320 | ctx.lineTo(side1[0] + side2[0], side1[1] + side2[1]); 321 | ctx.lineTo(side2[0], side2[1]); 322 | ctx.closePath(); 323 | ctx.fill(); 324 | ctx.stroke(); 325 | ctx.translate(-f.keyVert[0], -f.keyVert[1]); 326 | }); 327 | 328 | ctx.restore(); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /js/tiling.js: -------------------------------------------------------------------------------- 1 | import * as Vec from './vector.js'; 2 | 3 | const VERTEX_LIMIT = 20000; 4 | const CUT_CHECK_EPS = 1e3 * Number.EPSILON; 5 | 6 | export class TilingState { 7 | constructor(dims) { 8 | this.dims = Math.max(dims, 3); 9 | this.resetBasis(); 10 | this.resetOffset(); 11 | } 12 | 13 | resetBasis() { 14 | // Set up the basis vectors for the cutting plane. 15 | this.basis = [Vec.zero(this.dims), Vec.zero(this.dims)]; 16 | const f = Math.sqrt(2/this.dims); 17 | const g = ((this.dims % 2) + 1) * Math.PI / this.dims; 18 | for (let i = 0; i < this.dims; i++) { 19 | this.basis[0][i] = f*Math.cos(g*i); 20 | this.basis[1][i] = f*Math.sin(g*i); 21 | } 22 | // this.basisTest(); 23 | } 24 | 25 | basisTest() { 26 | console.debug('--------------------'); 27 | console.debug('|u| =', Vec.norm(this.basis[0])); 28 | console.debug('|v| =', Vec.norm(this.basis[1])); 29 | console.debug('u . v =', Vec.dot(this.basis[0], this.basis[1])); 30 | } 31 | 32 | resetOffset() { 33 | // const gamma = 0.1; // Penrose star 34 | const gamma = (this.dims === 5) ? 0.3 : 0; // Penrose sun 35 | this.offset = new Array(this.dims); 36 | this.offset.fill(gamma); 37 | } 38 | 39 | // Project a point in space onto the view plane. 40 | project(v) { 41 | return Vec.project(Vec.sub(v, this.offset), this.basis); 42 | } 43 | 44 | // Take a point on the view plane to the corresponding point in space. 45 | unproject(x) { 46 | return Vec.add(Vec.combine(x[0], this.basis[0], x[1], this.basis[1]), this.offset); 47 | } 48 | 49 | // Move the view center to the given (x,y) coords in the view plane. 50 | translateOffset(x, y) { 51 | this.offset = this.unproject([x, y]) 52 | .map((a) => a - Math.floor(a)); 53 | } 54 | 55 | moveAxis(changeAxis, x, y) { 56 | // Clamp u_i and v_i to inside the unit circle. 57 | let unit_x = x; 58 | let unit_y = y; 59 | let k = Math.hypot(x, y); 60 | if (k <= Number.EPSILON) { 61 | unit_x = 1; 62 | unit_y = 0; 63 | k = 0; 64 | } else { 65 | unit_x /= k; 66 | unit_y /= k; 67 | k = Math.min(k, 1); 68 | } 69 | 70 | // Convenient aliases for the basis vectors. 71 | let u = this.basis[0]; 72 | let v = this.basis[1]; 73 | 74 | // Rotate u and v so that u is nearly aligned with the change axis. 75 | const u1 = Vec.combine( unit_x, u, unit_y, v); 76 | const v1 = Vec.combine(-unit_y, u, unit_x, v); 77 | u = u1; 78 | v = v1; 79 | 80 | // Re-normalize v orthogonal to the change axis. 81 | Vec.renormalize(v, changeAxis, 0); 82 | 83 | // Make u orthogonal to v then re-normalize fully aligned with the change axis. 84 | u = Vec.makeOrtho(u, v); 85 | Vec.renormalize(u, changeAxis, k); 86 | 87 | // Rotate u and v to new direction. 88 | this.basis[0] = Vec.combine(unit_x, u, -unit_y, v); 89 | this.basis[1] = Vec.combine(unit_y, u, unit_x, v); 90 | 91 | // this.basisTest(); 92 | } 93 | 94 | // Enforce tiling state invariants. 95 | validate() { 96 | // Ensure the basis vectors are unit length and orthogonal. 97 | Vec.normalize(this.basis[0]); 98 | this.basis[1] = Vec.makeOrtho(this.basis[1], this.basis[0]); 99 | Vec.normalize(this.basis[1]); 100 | if (Vec.norm(this.basis[0]) === 0 || Vec.norm(this.basis[1]) === 0) { 101 | // If either vector ended up zero, the basis was invalid. 102 | // Reset it. 103 | this.resetBasis(); 104 | } 105 | 106 | // Ensure the offsets are clamped to [0,1]. 107 | for (let i = 0; i < this.dims; i++) { 108 | this.offset[i] = Math.max(0, Math.min(1, this.offset[i])); 109 | } 110 | } 111 | } 112 | 113 | class VertexCache { 114 | constructor(state, checkAxes) { 115 | this.state = state; 116 | this.checkAxes = checkAxes; 117 | this.cache = new Map(); 118 | } 119 | 120 | makeVertex(coord) { 121 | return { 122 | coord, 123 | pcoord: this.state.project(coord), 124 | inCut: this.checkAxes.cutCheck(coord), 125 | seen: false, 126 | }; 127 | } 128 | 129 | get(coord) { 130 | const key = coord.toString(); 131 | let v = this.cache.get(key); 132 | if (!v) { 133 | v = this.makeVertex(coord); 134 | this.cache.set(key, v); 135 | } 136 | return v; 137 | } 138 | 139 | getNeighbor(vertex, axis, delta) { 140 | const c = Vec.copy(vertex.coord); 141 | c[axis] += delta; 142 | return this.get(c); 143 | } 144 | } 145 | 146 | // Generate the tiling using the cut-and-project method. 147 | export function generateCutProj(state, viewWidth, viewHeight) { 148 | const checkAxes = new CheckAxes(state); 149 | const vertexCache = new VertexCache(state, checkAxes); 150 | const vertices = []; 151 | const hbound = 1.5 + viewWidth / 2; 152 | const vbound = 1.5 + viewHeight / 2; 153 | 154 | // Start with all vertices in the hypercube that contains this.offset. 155 | const startQueue = [vertexCache.get(state.offset.map(Math.floor))]; 156 | for (let i = 0; i < state.dims; i++) { 157 | startQueue.push(...startQueue.map((v) => vertexCache.getNeighbor(v, i, 1))); 158 | } 159 | const checkQueue = startQueue.filter((v) => v.inCut); 160 | 161 | // Find vertices in the cut by depth-first traversal of the lattice. 162 | // let visited = 0; 163 | while (checkQueue.length > 0 && vertices.length < VERTEX_LIMIT) { 164 | const checkVertex = checkQueue.pop(); 165 | // visited += 1; 166 | if (checkVertex.seen) { 167 | continue; 168 | } 169 | checkVertex.seen = true; 170 | 171 | // If in cut and not out of bounds, then keep this vertex. 172 | if (!checkVertex.inCut 173 | || Math.abs(checkVertex.pcoord[0]) > hbound 174 | || Math.abs(checkVertex.pcoord[1]) > vbound) { 175 | continue; 176 | } 177 | vertices.push(checkVertex); 178 | 179 | // Add neighbors to checkQueue. 180 | for (let i = 0; i < state.dims; i++) { 181 | for (let di of [-1,1]) { 182 | const neighbor = vertexCache.getNeighbor(checkVertex, i, di); 183 | if (!neighbor.seen) { 184 | checkQueue.push(neighbor); 185 | } 186 | } 187 | } 188 | } 189 | 190 | // Find edges and faces. 191 | // const edges = []; 192 | const faces = []; 193 | vertices.forEach((v) => { 194 | for (let i = 0; i < state.dims; i++) { 195 | const n1 = vertexCache.getNeighbor(v, i, 1); 196 | if (n1.inCut) { 197 | // edges.push([v.pcoord, n1.pcoord]); 198 | for (let j = i+1; j < state.dims; j++) { 199 | const n2 = vertexCache.getNeighbor(v, j, 1); 200 | const n3 = vertexCache.getNeighbor(n1, j, 1); 201 | if (n2.inCut && n3.inCut) { 202 | faces.push({ 203 | keyVert: v.pcoord, 204 | axis1: i, 205 | axis2: j, 206 | }); 207 | } 208 | } 209 | } 210 | } 211 | }); 212 | 213 | // console.debug('visited', visited, ':: kept', 214 | // vertices.length, 'vertices,', 215 | // this.edges.length, 'edges,', 216 | // this.faces.length, 'faces'); 217 | return faces; 218 | } 219 | 220 | class CheckAxes { 221 | constructor(state) { 222 | this.checkAxes = []; 223 | for (let i = 0; i < state.dims-2; i++) { 224 | for (let j = i+1; j < state.dims-1; j++) { 225 | for (let k = j+1; k < state.dims; k++) { 226 | const a = Vec.zero(state.dims); 227 | a[i] = state.basis[0][k]*state.basis[1][j] - state.basis[0][j]*state.basis[1][k]; 228 | a[j] = state.basis[0][i]*state.basis[1][k] - state.basis[0][k]*state.basis[1][i]; 229 | a[k] = state.basis[0][j]*state.basis[1][i] - state.basis[0][i]*state.basis[1][j]; 230 | if (Math.abs(a[i]) <= Number.EPSILON) { 231 | a[i] = 0; 232 | } 233 | if (Math.abs(a[j]) <= Number.EPSILON) { 234 | a[j] = 0; 235 | } 236 | if (Math.abs(a[k]) <= Number.EPSILON) { 237 | a[k] = 0; 238 | } 239 | Vec.normalize(a); 240 | if (Vec.norm(a) !== 0) { 241 | this.checkAxes.push(a); 242 | } 243 | } 244 | } 245 | } 246 | 247 | /* This is one way of resolving some corner cases, but not all. 248 | * Also, it wrecks some symmetry (when all offsets set to 0.5). 249 | * It works by making sure all checkAxes are on the same side of 250 | * the hyperplane defined by checkAxes[0]. 251 | */ 252 | // this.checkAxes.forEach((a,i) => { 253 | // if (Vec.dot(a, this.checkAxes[0]) < 0) { 254 | // Vec.scale(a, -1); 255 | // } 256 | // }); 257 | 258 | const cubeVerts = hypercubeVertices(state.dims) 259 | .map((v) => Vec.add(v, state.offset)); 260 | this.checkMax = []; 261 | this.checkMin = []; 262 | this.checkAxes.forEach((a) => { 263 | let max = -Infinity; 264 | let min = Infinity; 265 | cubeVerts.forEach((v) => { 266 | const d = Vec.dot(v, a); 267 | max = Math.max(max, d); 268 | min = Math.min(min, d); 269 | }); 270 | this.checkMax.push(max); 271 | this.checkMin.push(min); 272 | }); 273 | } 274 | 275 | cutCheck(v) { 276 | return this.checkAxes.every((a, i) => { 277 | const d = Vec.dot(v, a); 278 | // test that checkMin < d <= checkMax 279 | return d - this.checkMin[i] > CUT_CHECK_EPS 280 | && this.checkMax[i] - d >= -CUT_CHECK_EPS; 281 | }); 282 | } 283 | 284 | cutStatus(v) { 285 | return this.checkAxes.map((a, i) => { 286 | const d = Vec.dot(v, a); 287 | if (Math.abs(this.checkMax[i] - d) <= CUT_CHECK_EPS) { 288 | return '+'; 289 | } else if (Math.abs(d - this.checkMin[i]) <= CUT_CHECK_EPS) { 290 | return '-'; 291 | } 292 | return this.checkMin[i] < d && d < this.checkMax[i] ? '=' : '_'; 293 | }).join(''); 294 | } 295 | } 296 | 297 | function hypercubeVertices(n) { 298 | const vs = []; 299 | const v = Vec.zero(n); 300 | const walk = function (k) { 301 | if (k > 0) { 302 | v[k-1] = 0.5; 303 | walk(k-1); 304 | v[k-1] = -0.5; 305 | walk(k-1); 306 | } else { 307 | vs.push(Vec.copy(v)); 308 | } 309 | }; 310 | walk(n); 311 | return vs; 312 | } 313 | 314 | // Generate the tiling using the multigrid method. 315 | export function generateMultigrid(state, viewWidth, viewHeight) { 316 | // It will be useful to have the projection of each axis on the view plane. 317 | // This is the same as the transpose of the basis. 318 | const axis = []; 319 | for (let i = 0; i < state.dims; i++) { 320 | axis.push([state.basis[0][i], state.basis[1][i]]); 321 | } 322 | 323 | const extProd = getExtProds(axis); 324 | const hbound = viewWidth/2 + Math.SQRT1_2; 325 | const vbound = viewHeight/2 + Math.SQRT1_2; 326 | const grid = getGridRanges(state, hbound, vbound); 327 | 328 | const faces = []; 329 | for (let i = 0; i < state.dims-1; i++) { 330 | for (let j = i+1; j < state.dims; j++) { 331 | if (Math.abs(extProd[i][j]) < Number.EPSILON) { 332 | // Axis i and axis j are parallel. 333 | // Faces with this orientation have zero area / are perpendicular 334 | // to the cut plane, so they do not produce tiles. 335 | continue; 336 | } 337 | 338 | for (let ki = grid.min[i]; ki < grid.max[i]; ki++) { 339 | for (let kj = grid.min[j]; kj < grid.max[j]; kj++) { 340 | const faceVert = getFaceVertex(i, j, ki, kj, state, axis, extProd); 341 | const f1 = state.project(faceVert); 342 | 343 | const mid_x = f1[0] + (axis[i][0] + axis[j][0])/2; 344 | const mid_y = f1[1] + (axis[i][1] + axis[j][1])/2; 345 | if (Math.abs(mid_x) > hbound || Math.abs(mid_y) > vbound) { 346 | continue; 347 | } 348 | 349 | faces.push({ 350 | keyVert: f1, 351 | axis1: i, 352 | axis2: j, 353 | }); 354 | } 355 | } 356 | } 357 | } 358 | return faces; 359 | } 360 | 361 | function getFaceVertex(i, j, ki, kj, state, axis, extProd) { 362 | // Find the intersection (a,b) of the grid lines ki and kj. 363 | const u = ki + 0.5 - state.offset[i]; 364 | const v = kj + 0.5 - state.offset[j]; 365 | const a = (u*axis[j][1] - v*axis[i][1]) / extProd[i][j]; 366 | const b = (v*axis[i][0] - u*axis[j][0]) / extProd[i][j]; 367 | 368 | // Find the coordinates of the key vertex for the face 369 | // corresponding to this intersection. 370 | return state.unproject([a, b]).map((x, ix) => { 371 | if (ix === i) { 372 | return ki; 373 | } else if (ix === j) { 374 | return kj; 375 | } 376 | 377 | // Check for multiple intersections. If the fractional part of this coord 378 | // is 0.5, then it is on one of the grid lines for ix. 379 | if (Math.abs(x - Math.floor(x) - 0.5) > 1e-10) { 380 | return Math.round(x); 381 | } else if (axis_between(i, j, ix, axis, extProd)) { 382 | return Math.ceil(x); 383 | } else { 384 | return Math.floor(x); 385 | } 386 | }); 387 | } 388 | 389 | function axis_between(a1, a2, ax, axis, extProd) { 390 | if (Math.abs(extProd[a1][ax]) < Number.EPSILON) { 391 | // Axis a1 and ax are parallel. Shift the tile in the 392 | // ax direction if they point the same direction 393 | // AND this is the tile such that ax < a1. 394 | return Vec.dot(axis[ax], axis[a1]) > 0 && ax < a1; 395 | 396 | } else if (Math.abs(extProd[ax][a2]) < Number.EPSILON) { 397 | // Axis a2 and ax are parallel. Shift the tile in the 398 | // ax direction if they point the same direction 399 | // AND this is the tile such that ax < a2. 400 | return Vec.dot(axis[ax], axis[a2]) > 0 && ax < a2; 401 | 402 | } else { 403 | // Axis ax lies between axis a1 and axis a2. Shift the tile 404 | // in the ax direction by rounding up instead of down. 405 | return extProd[a1][a2]*extProd[a1][ax] > 0 406 | && extProd[a1][a2]*extProd[ax][a2] > 0; 407 | } 408 | } 409 | 410 | // It will also be useful to have the exterior products (aka perp dot products) 411 | // of each pair of axes. 412 | function getExtProds(axis) { 413 | const prods = []; 414 | for (let i = 0; i < axis.length; i++) { 415 | prods.push([]); 416 | for (let j = 0; j <= i; j++) { 417 | if (i === j) { 418 | prods[i][i] = 0; 419 | } else { 420 | prods[i][j] = axis[i][0]*axis[j][1] - axis[j][0]*axis[i][1]; 421 | prods[j][i] = -prods[i][j]; 422 | } 423 | } 424 | } 425 | return prods; 426 | } 427 | 428 | function getGridRanges(state, hbound, vbound) { 429 | // Find the range of grid lines for each axis. 430 | const corners = [ 431 | state.unproject([-hbound, -vbound]), 432 | state.unproject([-hbound, vbound]), 433 | state.unproject([ hbound, -vbound]), 434 | state.unproject([ hbound, vbound]), 435 | ]; 436 | const min = []; 437 | const max = []; 438 | for (let i = 0; i < state.dims; i++) { 439 | min.push(corners.reduce((acc, p) => Math.min(acc, Math.floor(p[i])), Infinity)); 440 | max.push(corners.reduce((acc, p) => Math.max(acc, Math.ceil(p[i])), -Infinity)); 441 | } 442 | return { min, max }; 443 | } 444 | --------------------------------------------------------------------------------